<?php
declare(strict_types=1);

require_once __DIR__ . '/db.php';
require_once __DIR__ . '/parts.php';
require_once __DIR__ . '/plugins.php';

/**
 * ============================================================
 *  フレーム（ガワ）
 * ------------------------------------------------------------
 *  frames/<key>/frame.json   … メタ（名前/説明/メイン画像の既定クラス）
 *  frames/<key>/layout.html  … 配布フレームのHTMLにスロットを開けたもの
 *  frames/<key>/css/style.css, theme.css, images/...
 *
 *  CMSは「配布フレームの既存HTML/CSS規約」に中身を流し込む。
 *  メイン画像・パーツは全フレーム共通の規約HTMLで出力し、
 *  各フレームのstyle.css + theme.css(変数) が見た目を与える。
 * ============================================================ */

function frame_all(): array {
    $out = [];
    foreach (glob(CMS_FRAMES . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
        $key = basename($dir);
        if (!is_file($dir . '/layout.html')) continue;
        $meta = json_array(@file_get_contents($dir . '/frame.json') ?: '');
        $meta['key']         = $key;
        $meta['name']        = $meta['name'] ?? $key;
        $meta['description'] = $meta['description'] ?? '';
        $meta['mainimg_class'] = $meta['mainimg_class'] ?? '';
        $out[$key] = $meta;
    }
    ksort($out);
    return $out;
}

function active_frame_key(): string {
    // 管理者プレビュー：?preview_frame=<key>（ログイン中のみ・保存は変えない）
    $preview = frame_preview_key();
    if ($preview !== '') return $preview;

    $key = setting_get('active_frame', 'frame16') ?? 'frame16';
    $all = frame_all();
    return isset($all[$key]) ? $key : (array_key_first($all) ?: 'frame16');
}

/** プレビュー対象フレーム（ログイン管理者が ?preview_frame で指定した時だけ有効） */
function frame_preview_key(): string {
    if (empty($_SESSION['admin_id'])) return '';
    $raw = isset($_GET['preview_frame']) ? (string)$_GET['preview_frame'] : '';
    $k = preg_replace('/[^a-zA-Z0-9_\-]/', '', $raw);
    if ($k === '') return '';
    $all = frame_all();
    return isset($all[$k]) ? $k : '';
}

/** フレーム内ファイルの公開URL */
function frame_asset_url(string $frameKey, string $rel): string {
    return url('/frames/' . $frameKey . '/' . ltrim($rel, '/'));
}

/** フレームのサムネ画像URL（frames/<key>/thumb.* があれば。無ければ空） */
function frame_thumb_url(string $frameKey): string {
    $hits = glob(CMS_FRAMES . '/' . $frameKey . '/thumb.*') ?: [];
    return $hits ? frame_asset_url($frameKey, basename($hits[0])) : '';
}

/* ============================================================
 *  フレーム固有の素材スロット（お客さんが差し替える、そのフレーム専用の素材）
 *  - frame.json の "assets":[{key,type,label,...}] で宣言（フレーム制作者が記入）
 *  - 値は設定キー frame_assets:<frameKey> に JSON でフレーム別に保存
 *    → 別フレームに切替えると使われず・管理画面にも出ず、戻れば復活
 *  - 描画時に ASSET_<key>（URL） / HAS_ASSET_<key>（有無）を差し込む
 * ============================================================ */

/** frame.json が宣言する素材枠の定義（[{key,type,label,hint?}]） */
function frame_asset_defs(array $meta): array {
    $defs = $meta['assets'] ?? null;
    if (!is_array($defs)) return [];
    $out = [];
    foreach ($defs as $d) {
        if (!is_array($d)) continue;
        $key = trim((string)($d['key'] ?? ''));
        if ($key === '' || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) continue;
        $out[] = [
            'key'     => $key,
            'type'    => (string)($d['type'] ?? 'image'),   // 当面は image
            'label'   => (string)($d['label'] ?? $key),
            'hint'    => (string)($d['hint'] ?? ''),
            'default' => trim((string)($d['default'] ?? '')), // 未設定時に使うフレーム同梱パス（例 images/decor.png）
        ];
    }
    return $out;
}

/** フレーム別に保存された素材の値（[key => 保存ファイル名]） */
function frame_assets_values(string $frameKey): array {
    return setting_get_json('frame_assets:' . $frameKey);
}

/** 素材の値を保存（管理画面から） */
function frame_assets_save(string $frameKey, array $values): void {
    setting_set('frame_assets:' . $frameKey, json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}

/**
 * 「この素材を使わない」フラグ（[key => 1]）。※frame_assets とは別キーで持つ。
 * 理由：同じ箱に入れて上書きすると、お客さんがアップ済みの画像のファイル名が失われる。
 * 別キーなら OFF にしてもファイル名は残り、チェックを外すだけで元の画像が復活する
 * （メイン画像・着せ替えと同じ「消さない」原則）。
 * キーに frameKey が入るので、別フレームへ切り替えても持ち込まれない／戻れば復活する。
 */
function frame_assets_off_values(string $frameKey): array {
    return setting_get_json('frame_assets_off:' . $frameKey);
}

/** 「この素材を使わない」フラグを保存（管理画面から） */
function frame_assets_off_save(string $frameKey, array $offs): void {
    setting_set('frame_assets_off:' . $frameKey, json_encode($offs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}

/** 描画用コンテキスト（ASSET_<key> / HAS_ASSET_<key>） */
function frame_asset_ctx(string $frameKey, array $meta): array {
    $defs = frame_asset_defs($meta);
    if ($defs === []) return [];
    $vals = frame_assets_values($frameKey);
    $offs = frame_assets_off_values($frameKey);
    $ctx = [];
    foreach ($defs as $d) {
        $k    = $d['key'];
        $file = trim((string)($vals[$k] ?? ''));
        $off  = !empty($offs[$k]);                     // 「この素材を使わない」
        if ($off) {
            // 使わない：差し替え画像も既定画像も出さない。
            // ※$file（アップ済みのファイル名）はそのまま残す＝チェックを外せば元の画像が復活する。
            $url = '';
        } elseif ($file !== '') {
            // お客さんが差し替えた画像
            $url = upload_url($file);
        } elseif ($d['default'] !== '') {
            // 未設定 → フレーム同梱の既定画像（images/xxx 等）
            $url = frame_asset_url($frameKey, $d['default']);
        } else {
            $url = '';
        }
        $ctx['ASSET_' . $k]        = $url;
        $ctx['HAS_ASSET_' . $k]    = $url !== '';          // 表示できる画像がある（差し替え or 既定）
        $ctx['CUSTOM_ASSET_' . $k] = (!$off && $file !== ''); // お客さんが差し替えたか（CSS上書き判定用）
        // 「使わない」を選んだとき用。フレームの style.css に飾りが直書きされている型
        // （CUSTOM_ASSET でだけ上書きするフレーム）は、これで打ち消しCSSを出す：
        //   {{#OFF_ASSET_bg_decor}}<style>body{background-image:none;}</style>{{/OFF_ASSET_bg_decor}}
        // ※打ち消すセレクタはフレームごとに違う（body / body.home 等）のでフレーム側の責務。
        $ctx['OFF_ASSET_' . $k]    = $off;
    }
    return $ctx;
}

/* ============================================================
 *  メイン画像（フレーム所有）
 *  - frames/<key>/mainimg.html があれば、それをエンジンで描画。
 *    CMSは素材だけ（画像リスト・動画・キャッチ）を渡す。
 *  - 無ければ CMS既定（静止画／動画。JS不要・空白回避）でフォールバック。
 *  - スライド等の演出は mainimg.html ＋ frames/<key>/main.js で。
 * ============================================================ */

/** フレームのメイン画像タイプ（static | slideshow | video）。旧 mainvisual_mode も読む */
function frame_mainvisual_type(array $meta): string {
    $t = (string)($meta['mainvisual_type'] ?? '');
    if ($t === '') {
        $m = (string)($meta['mainvisual_mode'] ?? 'static');
        $t = ($m === 'slideshow') ? 'slideshow' : 'static';
    }
    return in_array($t, ['static', 'slideshow', 'video'], true) ? $t : 'static';
}

/**
 * 静止画タイプで使うメイン画像の枚数（frame.json の "mainvisual_count"・既定1）。
 * 2枚組・3枚組など「複数の静止画を同時に配置する」フレーム用。
 * 並べ方（左右／上下／グリッド）を決めるのはフレームの CSS ＝ エンジンは枚数だけ渡す。
 * スライドショー（元から無制限）・動画タイプでは使わない。
 * frame.json に変な値を書かれても壊れないよう 1〜6 にクランプする。
 */
function frame_mainvisual_count(array $meta): int {
    $n = (int)($meta['mainvisual_count'] ?? 1);
    if ($n < 1) return 1;
    if ($n > 6) return 6;
    return $n;
}

/** リンク先（http(s)/# はそのまま、それ以外はページslug） */
function cms_link_href(string $t): string {
    $t = trim($t);
    if ($t === '') return '';
    if (preg_match('#^https?://#i', $t) || $t[0] === '#') return $t;
    return page_url($t);
}

/**
 * 任意入力のURLを安全な範囲に限定する（格納型XSS対策）。
 *  - 許可: http(s):// / mailto: / tel: / #アンカー / / で始まる相対・ルート相対パス / 通常の相対パス
 *  - 不許可: javascript: data: vbscript: など未知スキーム → 空文字を返す
 *  - 制御文字（タブ等でスキームを偽装する手口）は除去してから判定
 */
function cms_safe_url(string $u): string {
    $u = preg_replace('/[\x00-\x1F\x7F]/', '', trim($u));
    if ($u === '') return '';
    if ($u[0] === '#' || $u[0] === '/' || $u[0] === '?') return $u;
    if (preg_match('#^(https?:|mailto:|tel:)#i', $u)) return $u;
    // 何らかのスキーム（先頭に「語:」）を含むがホワイトリスト外 → 遮断
    if (preg_match('#^[a-z][a-z0-9+.\-]*:#i', $u)) return '';
    return $u; // スキーム無しの相対パス
}

/** テキスト位置 → クラス（左/中央/右はそれぞれ *-parts クラスを付与。auto＝おまかせはクラス無し＝フレーム標準） */
function mv_align_class(string $a): string {
    if ($a === 'left')   return 'left left-parts';
    if ($a === 'right')  return 'right right-parts';
    if ($a === 'center') return 'center center-parts'; // フレーム標準が左/右でも中央に強制できるよう明示クラスを出す
    return ''; // auto（おまかせ＝フレーム標準位置。クラスは付けない）
}

/**
 * メイン画像のスライド配列を取得。
 * 新形式 slides[] を優先。旧形式（images[]＋共通テキスト）は1枚目にテキストを載せて移行。
 */
function mainvisual_slides(array $mv): array {
    if (isset($mv['slides']) && is_array($mv['slides'])) return array_values($mv['slides']);
    $out = [];
    foreach (array_values(array_filter((array)($mv['images'] ?? []), 'strlen')) as $i => $img) {
        $s = ['image' => $img];
        if ($i === 0) {
            $s['catch'] = (string)($mv['catch'] ?? '');
            $s['sub']   = (string)($mv['sub'] ?? '');
            $s['align'] = (string)($mv['align'] ?? '');
            $s['btn1_text'] = (string)($mv['btn1_text'] ?? '');
            $s['btn1_url']  = (string)($mv['btn1_url'] ?? '');
            $s['btn2_text'] = (string)($mv['btn2_text'] ?? '');
            $s['btn2_url']  = (string)($mv['btn2_url'] ?? '');
        }
        $out[] = $s;
    }
    return $out;
}

/* ============================================================
 *  メイン画像のフォールバック（フレーム同梱の規則命名素材を自動検出）
 *  - お客さんがメイン画像を1枚もアップしていない時だけ使う（オール・オア・ナッシング）
 *  - 命名規則：images/mainimgN.* （PC）＋ images/mainimgN_s.*（スマホ用・任意）
 *              動画は images/movie.* ＋ images/movie_s.*（任意）
 *  - JSON宣言は不要。種類は frame.json の mainvisual_type で判定。
 * ============================================================ */

/** フレーム同梱のフォールバック画像を検出（[['image_url','image_sp_url'], …]） */
function frame_default_mainimages(string $frameKey, string $type, int $count = 1): array {
    $dir = CMS_FRAMES . '/' . $frameKey . '/images';
    if (!is_dir($dir)) return [];
    $out = [];
    for ($n = 1; $n <= 30; $n++) {
        $hits = glob($dir . '/mainimg' . $n . '.*') ?: []; // mainimgN_s.* は "." 不一致で除外される
        if ($hits === []) break;
        $main = basename($hits[0]);
        $spHits = glob($dir . '/mainimg' . $n . '_s.*') ?: [];
        $sp = $spHits ? basename($spHits[0]) : '';
        $out[] = [
            'image_url'    => frame_asset_url($frameKey, 'images/' . $main),
            'image_sp_url' => $sp !== '' ? frame_asset_url($frameKey, 'images/' . $sp) : '',
        ];
        // 静止画・その他は frame.json の mainvisual_count 枚まで（既定1）。スライドショーは見つかるだけ全部。
        if ($type !== 'slideshow' && count($out) >= max(1, $count)) break;
    }
    return $out;
}

/** フレーム同梱のフォールバック動画を検出（['video_url','video_sp_url'] or []） */
function frame_default_video(string $frameKey): array {
    $dir = CMS_FRAMES . '/' . $frameKey . '/images';
    if (!is_dir($dir)) return [];
    // 動画拡張子(mp4/webm)だけを対象にする（movie.jpg 等のポスター画像を誤って動画ソースにしないため）。
    // ※glob('movie.*') だと movie.jpg が movie.mp4 より先に並んで拾われてしまう不具合があった（2026-06-26修正）。
    $hits   = array_merge(glob($dir . '/movie.mp4')   ?: [], glob($dir . '/movie.webm')   ?: []);
    if ($hits === []) return [];
    $spHits = array_merge(glob($dir . '/movie_s.mp4') ?: [], glob($dir . '/movie_s.webm') ?: []);
    return [
        'video_url'    => frame_asset_url($frameKey, 'images/' . basename($hits[0])),
        'video_sp_url' => $spHits ? frame_asset_url($frameKey, 'images/' . basename($spHits[0])) : '',
    ];
}

/** 1スライド分の描画用コンテキスト（テキストはエスケープ＋改行→<br>済み） */
function mv_slide_ctx(array $s, int $i, bool $lockAlign = false, bool $hideText = false, bool $hideButton = false): array {
    $catch = (string)($s['catch'] ?? '');
    $sub   = (string)($s['sub'] ?? '');
    $b1t   = trim((string)($s['btn1_text'] ?? ''));
    $b1u   = trim((string)($s['btn1_url'] ?? ''));
    $b2t   = trim((string)($s['btn2_text'] ?? ''));
    $b2u   = trim((string)($s['btn2_url'] ?? ''));
    // frame.json で mainvisual_text_hide=true（ロゴだけ表示するフレーム等）の時は、テキスト・ボタンを一切出さない
    if ($hideText) { $catch = ''; $sub = ''; $b1t = ''; $b1u = ''; $b2t = ''; $b2u = ''; }
    if ($hideButton) { $b1t = ''; $b1u = ''; $b2t = ''; $b2u = ''; } // mainvisual_no_button：ボタンだけ消す（テキストは残す）
    // 画像URL：image_url（解決済み＝フレーム同梱フォールバック用）優先、無ければアップロード名から
    $url = !empty($s['image_url']) ? (string)$s['image_url']
         : (!empty($s['image']) ? upload_url((string)$s['image']) : '');
    $spRaw = trim((string)($s['image_sp'] ?? ''));
    $spUrl = !empty($s['image_sp_url']) ? (string)$s['image_sp_url']
           : ($spRaw !== '' ? upload_url($spRaw) : '');
    // 文字サイズ（大/中/小）。この項目を持たない古いデータ・初期デモでもキーを直接読まない
    //（?? で受けた値をそのまま使う。判定だけ ?? で守って本体を素で読むと Warning が出る）。
    $ts = (string)($s['text_size'] ?? 'md');
    if (!in_array($ts, ['lg', 'md', 'sm'], true)) $ts = 'md';
    return [
        'url'           => $url,
        'MV_SP'         => $spUrl,
        'HAS_SP'        => $spUrl !== '',
        'n'             => $i + 1,
        'is_first'      => $i === 0,
        'MV_CATCH'      => nl2br(h($catch), false), 'HAS_CATCH' => $catch !== '',
        'MV_SUB'        => nl2br(h($sub), false),   'HAS_SUB'   => $sub !== '',
        'MV_TEXT_CLASS' => trim(($lockAlign ? '' : mv_align_class((string)($s['align'] ?? ''))) . ' mv-size-' . $ts),
        'MV_BTN1_TEXT'  => $b1t, 'MV_BTN1_URL' => cms_link_href($b1u), 'HAS_BTN1' => $b1t !== '',
        'MV_BTN1_CLASS' => mv_btn_class((string)($s['btn1_style'] ?? ''), false),
        'MV_BTN2_TEXT'  => $b2t, 'MV_BTN2_URL' => cms_link_href($b2u), 'HAS_BTN2' => $b2t !== '',
        'MV_BTN2_CLASS' => mv_btn_class((string)($s['btn2_style'] ?? ''), true),
        'HAS_TEXT'      => ($catch !== '' || $sub !== '' || $b1t !== ''),
    ];
}

/** ボタンのスタイルクラス（fill=塗りつぶし / inverse=枠線）。未指定時は既定に従う */
function mv_btn_class(string $style, bool $defaultInverse): string {
    if ($style === 'inverse') return 'btn inverse';
    if ($style === 'fill')    return 'btn';
    return $defaultInverse ? 'btn inverse' : 'btn';
}

/** 既定メイン画像のテキスト＋ボタンブロック（mainimg.html を持たないフレーム用） */
function build_mainvisual_text_default(array $v): string {
    if (empty($v['HAS_TEXT'])) return '';
    $cls = trim('text ' . (string)($v['MV_TEXT_CLASS'] ?? ''));
    $h = '<div class="' . h($cls) . '">';
    if (!empty($v['HAS_CATCH']) || !empty($v['HAS_SUB'])) {
        // MV_CATCH / MV_SUB は既にエスケープ＋<br>済みなので生出力
        $h .= '<h2>' . (string)$v['MV_CATCH']
            . (!empty($v['HAS_SUB']) ? '<span>' . (string)$v['MV_SUB'] . '</span>' : '') . '</h2>';
    }
    if (!empty($v['HAS_BTN1']) || !empty($v['HAS_BTN2'])) {
        $h .= '<div class="btn-container">';
        if (!empty($v['HAS_BTN1'])) {
            $h .= '<div class="' . h((string)($v['MV_BTN1_CLASS'] ?? 'btn')) . '"><a href="' . h((string)$v['MV_BTN1_URL']) . '">' . h((string)$v['MV_BTN1_TEXT']) . '</a></div>';
        }
        if (!empty($v['HAS_BTN2'])) {
            $h .= '<div class="' . h((string)($v['MV_BTN2_CLASS'] ?? 'btn inverse')) . '"><a href="' . h((string)$v['MV_BTN2_URL']) . '">' . h((string)$v['MV_BTN2_TEXT']) . '</a></div>';
        }
        $h .= '</div>';
    }
    $h .= '</div>';
    return $h;
}

/** スライド/テキストにキャッチ・サブ・ボタン文字のいずれかが入っているか */
function mv_text_present(array $s): bool {
    foreach (['catch', 'sub', 'btn1_text', 'btn2_text'] as $k) {
        if (trim((string)($s[$k] ?? '')) !== '') return true;
    }
    return false;
}

/** 動画など「先頭の共通テキスト」を取得：slides[0] を優先、無ければ旧 video_text（後方互換） */
function mv_primary_text(array $mv, array $slides): array {
    $first = (isset($slides[0]) && is_array($slides[0])) ? $slides[0] : [];
    if (!mv_text_present($first) && is_array($mv['video_text'] ?? null)) {
        return $mv['video_text']; // 旧データ互換
    }
    return $first;
}

function render_mainvisual(array $mv, array $meta, string $frameKey): string {
    $type    = frame_mainvisual_type($meta);
    $alignLock = !empty($meta['mainvisual_align_lock']); // frame.json で位置固定＝位置クラスを付けず、フレームCSSの固定レイアウトに任せる
    $textHide  = !empty($meta['mainvisual_text_hide']);  // frame.json でメイン画像のテキスト・ボタンを一切出さない（ロゴ中央配置型など）
    $btnHide   = !empty($meta['mainvisual_no_button']);  // frame.json でボタンだけ非表示（テキスト＝キャッチ/サブは出す）
    $video   = trim((string)($mv['video'] ?? ''));
    $videoSp = trim((string)($mv['video_sp'] ?? ''));
    $slides = mainvisual_slides($mv);
    $imgSlides = array_values(array_filter($slides, fn($s) => !empty($s['image'])));
    // スライドショー以外（静止画・動画）は frame.json の mainvisual_count 枚だけ使う（既定1＝従来どおり）。
    // 2枚組・3枚組レイアウトのフレームはここで宣言枚数ぶん渡り、並べ方はフレーム側のCSSが決める。
    $mvCount = frame_mainvisual_count($meta);
    if ($type !== 'slideshow') $imgSlides = array_slice($imgSlides, 0, $mvCount);

    // メイン画像はフレームの mainvisual_type に従う。
    // ※ mainvisual データは全フレーム共通の1個なので、他タイプの残り（動画/画像）が
    //   混ざらないよう、自分のタイプの素材だけを使う（フォールバックもタイプ別）。
    $fbVideoUrl = ''; $fbVideoSpUrl = '';
    if ($type === 'video') {
        // 動画タイプ：顧客動画が無ければフレーム標準動画
        if ($video === '') {
            $fv = frame_default_video($frameKey);
            if ($fv) { $fbVideoUrl = $fv['video_url']; $fbVideoSpUrl = $fv['video_sp_url']; }
        }
    } else {
        // 静止画・スライド：顧客画像が無ければフレーム標準画像（グローバルvideoは無視）
        if ($imgSlides === []) {
            foreach (frame_default_mainimages($frameKey, $type, $mvCount) as $i => $f) {
                $base = (isset($slides[$i]) && is_array($slides[$i])) ? $slides[$i] : [];
                $base['image_url']    = $f['image_url'];
                $base['image_sp_url'] = $f['image_sp_url'];
                $imgSlides[] = $base;
            }
        }
    }

    // 動画として描画するか（動画タイプ＋顧客動画 or フレーム標準動画があるとき）
    $useVideo   = ($type === 'video') && ($video !== '' || $fbVideoUrl !== '');
    $videoUrl   = $useVideo ? ($video !== '' ? upload_url($video) : $fbVideoUrl) : '';
    $videoSpUrl = $useVideo ? ($video !== '' ? ($videoSp !== '' ? upload_url($videoSp) : '') : $fbVideoSpUrl) : '';

    if ($imgSlides === [] && !$useVideo) return ''; // 素材なし

    // 各スライド（テキスト込み）
    $imgList = [];
    foreach ($imgSlides as $i => $s) $imgList[] = mv_slide_ctx($s, $i, $alignLock, $textHide, $btnHide);

    // 動画など先頭の共通テキスト（slides[0] に一本化。旧 video_text は後方互換で拾う）
    $vtext = mv_primary_text($mv, $slides);
    $vctx  = mv_slide_ctx($vtext, 0, $alignLock, $textHide, $btnHide);
    unset($vctx['url'], $vctx['n'], $vctx['is_first']);

    // メイン画像の文字色。標準('')はフレームCSSのまま。
    // scopeは「.mainimg の中の h1〜h6 / p」を直接狙う＝**中間に .text 等のclassを要求しない**（どんなフレーム構造でも効く）。
    // スライドショー以外（静止画・動画・共通テキスト）は `.mainimg` 全体に1色。
    // スライドショーのみ各スライド `.imgN` 単位で個別指定（スライド別の色を出せるよう、スライドのラッパ class が必要）。
    $singleText = ($type !== 'slideshow') || !empty($meta['mainvisual_shared_text']);
    $tcCss = '';
    if ($singleText) {
        $m = (string)($vtext['text_color'] ?? '');
        if ($m === 'light' || $m === 'dark') {
            // `.mainimg` の中に加え、テキストを `.mainimg` の外に置くフレーム（例：frame11 の `.mainimg-container > .text`）にも届かせる。
            // `.mainimg-container` を持たないフレームではこのセレクタは一致しない＝無害。持つフレームは内外どちらの見出し/本文も拾う。
            $tcCss .= mv_text_color_rule('.mainimg', $m) . mv_text_color_rule('.mainimg-container', $m);
        }
    } else {
        foreach ($imgSlides as $i => $s) {
            $m = (string)($s['text_color'] ?? '');
            if ($m === 'light' || $m === 'dark') $tcCss .= mv_text_color_rule('.mainimg .img' . ($i + 1), $m);
        }
    }
    // 文字色の上書きは「テキストが画像に重なっている間だけ」効かせる。
    // 小さい端末でテキストを画像の外（下や上）へ段落ちさせるフレームがあり、そこは背景が白なので
    // 白文字を選ぶと読めなくなる。frame.json の mainvisual_text_overlay_media に「重なっている条件」を
    // 書いておけば、その条件の中だけ上書きが効き、外れた時はフレーム本来の文字色に戻る。
    // 書かないフレームは今までどおり無条件（＝常に重なっているフレーム）。
    $tcStyle = '';
    if ($tcCss !== '') {
        $tcMedia = mv_text_overlay_media($meta);
        if ($tcMedia !== '') $tcCss = '@media ' . $tcMedia . '{' . $tcCss . '}';
        $tcStyle = '<style>' . $tcCss . '</style>';
    }

    $ctx = array_merge($vctx, [
        'BASE'       => BASE_URL,   // フレーム同梱アセット参照用（{{BASE}}/frames/{{FRAME}}/images/...）
        'FRAME'      => $frameKey,
        'HAS_VIDEO'    => $videoUrl !== '',
        'MV_VIDEO'     => $videoUrl,
        'HAS_VIDEO_SP' => $videoSpUrl !== '',
        'MV_VIDEO_SP'  => $videoSpUrl,
        'HAS_IMAGES' => $imgList !== [],
        'MV_IMAGES'  => $imgList,
        'MV_FIRST'   => $imgList !== [] ? $imgList[0]['url'] : '',
    ]);
    // フレーム固有素材（メイン画像内でも ASSET_<key> を使えるように）
    $ctx += frame_asset_ctx($frameKey, $meta);

    // フレーム所有の mainimg.html があればエンジンで描画
    $tplFile = CMS_FRAMES . '/' . $frameKey . '/mainimg.html';
    if (is_file($tplFile)) {
        return $tcStyle . render_template((string)file_get_contents($tplFile), $ctx);
    }

    // 既定（mainimg.html 無し）。タイプ優先＋空白回避
    $showVideo = $useVideo;
    if ($showVideo) {
        $text = build_mainvisual_text_default($vctx);
        // スマホ用動画があれば media 付き source を先に（無ければパソコン用のみ）
        $vsrc = '';
        if ($videoSpUrl !== '') {
            $vsrc .= '<source src="' . h($videoSpUrl) . '" media="(max-width:800px)" type="video/mp4">';
        }
        $vsrc .= '<source src="' . h($videoUrl) . '" type="video/mp4">';
        return $tcStyle . '<div class="mainimg video-zone"><div class="slide img1">' . $text
            . '<video class="bgVideo" autoplay muted loop playsinline preload="metadata">'
            . $vsrc . '</video></div></div>';
    }
    if ($imgList !== []) {
        $first = $imgList[0];
        $text = build_mainvisual_text_default($first);
        $source = !empty($first['HAS_SP'])
            ? '<source media="(max-width:800px)" srcset="' . h((string)$first['MV_SP']) . '">'
            : '';
        return $tcStyle . '<div class="mainimg"><div class="slide img1">' . $text
            . '<picture>' . $source . '<img src="' . h((string)$first['url']) . '" alt=""></picture></div></div>';
    }
    return '';
}

/* ============================================================
 *  ナビ（メニュー管理のデータから生成・2階層対応）
 * ============================================================ */

/** メニュー項目のリンク先URL */
function menu_item_href(array $it): string {
    $type = $it['link_type'] ?? 'page';
    $link = (string)($it['link'] ?? '');
    if ($type === 'page')   return page_url($link);
    if ($type === 'url')    return cms_safe_url($link);
    if ($type === 'anchor') return cms_safe_url($link); // 例: #contact / /about#team
    return ''; // heading 等
}

/**
 * メニューHTMLを生成。
 *  $mode = 'all'    … 単一メニュー型フレーム用。1つのHTMLをPC/モバイル兼用にし、
 *                     項目に hide-pc / hide-mobile クラスを付けてCSSで出し分ける。
 *  $mode = 'pc'     … 2メニュー型フレームのPCヘッダー用。「PC表示ON」の項目だけ出力。
 *  $mode = 'mobile' … 2メニュー型フレームのスマホ#menubar用。「スマホ表示ON」の項目だけ出力。
 * ※2メニュー型は別々のHTMLなので、CSSで隠すのではなく最初から振り分けて出すのが確実。
 */
/** 一般訪問者に隠すページslug（status=draft＝非公開）。管理者ログイン中は空＝全部見える。 */
function cms_hidden_page_slugs(): array {
    if (!empty($_SESSION['admin_id'])) return [];
    static $c = null;
    if ($c === null) {
        $c = [];
        try {
            foreach (db()->query("SELECT slug FROM pages WHERE status = 'draft'") as $r) {
                $c[(string)$r['slug']] = true;
            }
        } catch (Throwable $e) {}
    }
    return $c;
}

function build_nav(string $currentSlug, string $mode = 'all'): string {
    $items = menu_all();
    // 見出し（link_type=heading）はサイド型(2カラム)フレームのときだけ出す。
    // 1カラムフレームに切り替えても見出しデータは消さず、出力しないだけ（着せ替えで復活）。
    $isSidebar = !empty((frame_all()[active_frame_key()] ?? [])['menu_sidebar']);
    $hidden = cms_hidden_page_slugs(); // 非公開ページへのメニュー項目は一般訪問者に出さない
    $isHidden = fn(array $it): bool => (($it['link_type'] ?? '') === 'page' && isset($hidden[(string)($it['link'] ?? '')]));
    $tops = [];
    $children = [];
    foreach ($items as $it) {
        if ($it['parent_id'] !== null && $it['parent_id'] !== '') {
            $children[(int)$it['parent_id']][] = $it;
        } else {
            $tops[] = $it;
        }
    }

    $renderLink = function (array $it): string {
        if (($it['link_type'] ?? '') === 'heading') {
            // 見出しは <li class="menu-heading"><span>…</span></li> の形（class は li 側に付ける＝下記）。
            return '<span>' . h($it['label']) . '</span>';
        }
        return '<a href="' . h(menu_item_href($it)) . '">' . h($it['label']) . '</a>';
    };

    $lis = '';
    foreach ($tops as $t) {
        if ($isHidden($t)) continue; // 非公開ページへの項目は一般訪問者に出さない
        if (!$isSidebar && ($t['link_type'] ?? '') === 'heading') continue; // 見出しはサイド型フレームのみ
        // 用途別メニュー（2メニュー型）では表示対象の項目だけを出す
        if ($mode === 'pc'     && empty($t['show_pc']))     continue;
        if ($mode === 'mobile' && empty($t['show_mobile'])) continue;

        $cls = [];
        if (($t['link_type'] ?? '') === 'heading') $cls[] = 'menu-heading';
        if (!empty($t['is_button'])) $cls[] = 'btn';
        if ($mode === 'all') {
            // 単一メニュー型のみ：CSSで出し分けるためのクラスを付与
            if (!$t['show_pc'])     $cls[] = 'hide-pc';
            if (!$t['show_mobile']) $cls[] = 'hide-mobile';
        }
        if (($t['link_type'] ?? '') === 'page' && (string)$t['link'] === $currentSlug) $cls[] = 'current';
        $clsAttr = $cls ? ' class="' . implode(' ', $cls) . '"' : '';

        $kids = $children[(int)$t['id']] ?? [];
        $sub = '';
        if ($kids) {
            $s = '';
            foreach ($kids as $k) {
                if ($isHidden($k)) continue; // 非公開ページへの子項目も隠す
                if (!$isSidebar && ($k['link_type'] ?? '') === 'heading') continue; // 見出しはサイド型フレームのみ
                $kCls = (($k['link_type'] ?? '') === 'heading') ? ' class="menu-heading"' : '';
                $s .= '<li' . $kCls . '>' . $renderLink($k) . '</li>';
            }
            if ($s !== '') $sub = '<ul>' . $s . '</ul>';
        }
        $lis .= '<li' . $clsAttr . '>' . $renderLink($t) . $sub . '</li>';
    }
    return '<ul>' . $lis . '</ul>';
}

/* ============================================================
 *  SNS（全ページ共通データ）
 * ============================================================ */

/**
 * 対応SNSサービス（キー => ラベル・アイコン）
 * data/sns.json があればそれを使う（利用者がサービスを追加・編集できる）。
 * 無ければ下記の組み込み既定を使用。
 */
function sns_services(): array {
    static $cache = null;
    if ($cache !== null) return $cache;

    $defaults = [
        'x'         => ['label' => 'X（旧Twitter）', 'icon' => 'ti ti-brand-x'],
        'instagram' => ['label' => 'Instagram',      'icon' => 'ti ti-brand-instagram'],
        'facebook'  => ['label' => 'Facebook',       'icon' => 'ti ti-brand-facebook'],
        'line'      => ['label' => 'LINE',           'icon' => 'ti ti-brand-line'],
        'youtube'   => ['label' => 'YouTube',        'icon' => 'ti ti-brand-youtube'],
        'tiktok'    => ['label' => 'TikTok',         'icon' => 'ti ti-brand-tiktok'],
        'threads'   => ['label' => 'Threads',        'icon' => 'ti ti-brand-threads'],
        'linkedin'  => ['label' => 'LinkedIn',       'icon' => 'ti ti-brand-linkedin'],
        'pinterest' => ['label' => 'Pinterest',      'icon' => 'ti ti-brand-pinterest'],
    ];

    $file = CMS_DATA . '/sns.json';
    if (is_file($file)) {
        $j = json_array((string)@file_get_contents($file));
        $clean = [];
        foreach ($j as $key => $svc) {
            if (is_string($key) && is_array($svc) && isset($svc['label'], $svc['icon'])) {
                $clean[$key] = ['label' => (string)$svc['label'], 'icon' => (string)$svc['icon']];
            }
        }
        if ($clean) return $cache = $clean;
    }
    return $cache = $defaults;
}

/** SNSの保存値からURLを取り出す（旧形式 {url,on} も許容） */
function sns_url_of(array $data, string $key): string {
    $v = $data[$key] ?? '';
    if (is_array($v)) $v = $v['url'] ?? '';
    return cms_safe_url((string)$v); // javascript: 等を遮断
}

/** SNSアイコンのHTML（URLが入っているものだけ表示） */
function build_sns_html(): string {
    $data = setting_get_json('sns');
    $lis = '';
    foreach (sns_services() as $key => $svc) {
        $url = sns_url_of($data, $key);
        if ($url === '') continue;
        $lis .= '<li><a href="' . h($url) . '" target="_blank" rel="noopener" aria-label="' . h($svc['label']) . '">'
              . '<i class="' . h($svc['icon']) . '"></i></a></li>';
    }
    return $lis === '' ? '' : '<ul class="sns-list">' . $lis . '</ul>';
}

/* ============================================================
 *  フッター（デザイン選択式）
 *  footers/<key>/footer.json ＋ template.html を選んで使う。
 *  CMSが共通スロット（ロゴ/住所/SNS/メニュー/コピーライト）を流し込む。
 * ============================================================ */

/** フッターデザイン一覧 */
function footer_all(): array {
    $out = [];
    foreach (glob(CMS_ROOT . '/footers/*', GLOB_ONLYDIR) ?: [] as $dir) {
        $key = basename($dir);
        if (!is_file($dir . '/template.html')) continue;
        $meta = json_array(@file_get_contents($dir . '/footer.json') ?: '');
        $meta['key']         = $key;
        $meta['name']        = $meta['name'] ?? $key;
        $meta['description'] = $meta['description'] ?? '';
        $out[$key] = $meta;
    }
    ksort($out);
    return $out;
}

function active_footer_key(): string {
    $key = setting_get('footer_design', '') ?? '';
    $all = footer_all();
    if (isset($all[$key])) return $key;
    return array_key_first($all) ?: '';
}

/** フッターリンクのURL（http(s)/# はそのまま、それ以外はページslug） */
function footer_link_href(array $l): string {
    $t = trim((string)($l['target'] ?? ''));
    if ($t === '') return '';
    if (preg_match('#^https?://#i', $t) || $t[0] === '#') return $t;
    return page_url($t);
}

/** SNSをエンジン用の配列で（[{url,icon,label}]） */
function build_sns_items(): array {
    $data = setting_get_json('sns');
    $items = [];
    foreach (sns_services() as $key => $svc) {
        $url = sns_url_of($data, $key);
        if ($url === '') continue;
        $items[] = ['url' => $url, 'icon' => $svc['icon'], 'label' => $svc['label']];
    }
    return $items;
}

/** フッターメニューのグループ（[{heading, links:[{label,href}]}]） */
function build_footer_groups(): array {
    $groups = setting_get_json('footer_menu');
    $hidden = cms_hidden_page_slugs(); // 非公開ページへのリンクは一般訪問者に出さない（build_nav と同じ扱い）
    $out = [];
    foreach ($groups as $g) {
        if (!is_array($g)) continue;
        $heading = trim((string)($g['heading'] ?? ''));
        $links = [];
        $hadLink = false; // 元データにリンクがあったか（非公開ページの除外で空になった見出しを落とすため）
        foreach ((array)($g['links'] ?? []) as $l) {
            if (!is_array($l)) continue;
            $label = trim((string)($l['label'] ?? ''));
            if ($label === '') continue;
            $hadLink = true;
            // 非公開ページへのリンク（target がページslug）は非ログイン時に隠す
            $target = trim((string)($l['target'] ?? ''));
            if ($target !== '' && $target[0] !== '#'
                && !preg_match('#^https?://#i', $target)
                && isset($hidden[$target])) {
                continue;
            }
            $links[] = ['label' => $label, 'href' => footer_link_href($l)];
        }
        // リンクが無い場合：純粋な見出しのみグループは残すが、
        // 全リンクが非公開除外で消えたグループ（見出し＋リンク有り）は丸ごと落とす
        if ($links === [] && ($heading === '' || $hadLink)) continue;
        $out[] = ['heading' => $heading, 'links' => $links];
    }
    return $out;
}

/**
 * フッターの末尾に出す表記。
 * 無料版：著作クレジット（あなたの宣伝）。製品版：表記を消し、代わりに
 * 発行シリアルをHTMLコメントで埋める（不正解除の目視照合・抑止用）。
 */
function cms_footer_credit(): string {
    // 製品版：フッター末尾には何も出さない（発行シリアルは cms_serial_comment() が </body> 直前に出力する）。
    if (cms_is_licensed()) return '';
    // 無料版：著作クレジット（あなたの宣伝）をフッター末尾に表示。
    return '<span class="pr"><a href="https://frames-cms.com/" target="_blank" rel="noopener nofollow">Frames CMS｜着せ替えできるCMS</a></span>';
}

/**
 * 製品版のみ：発行シリアルをHTMLコメントで </body> 直前に出力（不正解除の目視照合・抑止用）。
 * 丸ごと出すとコメントからコピペで解除されうるため、末尾ブロック（後半5文字）は伏せる
 * （部分コードは書式不一致＝解除には使えない）。無料版・未解除では空文字。
 */
function cms_serial_comment(): string {
    if (!cms_is_licensed()) return '';
    $code = (string) cms_license_code();
    if ($code === '') return '';
    $parts = explode('-', $code);
    if (count($parts) > 1) array_pop($parts);
    return '<!-- ' . implode('-', $parts) . " -->\n";
}

function render_footer(string $copyright, string $year): string {
    $key = active_footer_key();
    $credit = cms_footer_credit();
    if ($key === '') return $credit; // フッターデザインなし

    $tpl = (string)@file_get_contents(CMS_ROOT . '/footers/' . $key . '/template.html');
    if ($tpl === '') return $credit;

    $siteName = trim((string)(setting_get('site_name', '') ?? ''));
    $tel      = trim((string)(setting_get('tel', '') ?? ''));
    $address  = trim((string)(setting_get('address', '') ?? ''));
    $text     = trim((string)(setting_get('footer_text', '') ?? ''));

    // ロゴ：フッター用ロゴ→ヘッダーロゴ→（どちらも無ければ）サイト名テキスト
    $flogo = setting_get('footer_logo', '') ?? '';
    $logo  = setting_get('logo', '') ?? '';
    $useLogo = $flogo !== '' ? $flogo : $logo;
    $hasLogo = $useLogo !== '';
    $logoUrl = $hasLogo ? upload_url($useLogo) : '';
    $logoHtml = $hasLogo
        ? '<img src="' . h($logoUrl) . '" alt="' . h($siteName) . '">'
        : h($siteName);

    // 住所＋電話（改行で連結）
    $addrTel = '';
    if ($address !== '') $addrTel .= h($address);
    if ($tel !== '')     $addrTel .= ($addrTel !== '' ? '<br>' : '') . h($tel);

    $snsItems = build_sns_items();
    $groups   = build_footer_groups();

    $ctx = [
        'SITE_NAME'     => $siteName,
        'LOGO'          => $logoHtml,
        'HAS_LOGO'      => $hasLogo,
        'LOGO_URL'      => $logoUrl,
        'ADDRESS'       => $address,
        'TEL'           => $tel,
        'ADDRESS_TEL'   => $addrTel,
        'FOOTER_TEXT'   => safe_richtext($text),
        'HAS_TEXT'      => $text !== '',
        'COPYRIGHT'     => $copyright,
        'YEAR'          => $year,
        'HAS_SNS'       => $snsItems !== [],
        'SNS_ITEMS'     => $snsItems,
        'HAS_MENU'      => $groups !== [],
        'FOOTER_GROUPS' => $groups,
    ];

    return render_template($tpl, $ctx) . $credit;
}

/* ============================================================
 *  共通アセットの自動読み込み
 * ============================================================ */

/** head用：共通＋パーツの全CSSを自動で読み込む（cms-extra は最後） */
/**
 * 管理画面（アクセス解析・外部タグ）で貼り付けた任意コードを取り出す。
 * サイト運営者本人（＝認証済み管理者）が入れる前提で、公開ページの <head>末尾 / </body>直前 にそのまま出力する。
 * GA4・Google タグマネージャ・Search Console 認証メタ・各種埋め込みなど。空なら何も出さない。
 */
function cms_head_code(): string {
    if (cms_demo_mode()) return ''; // デモモードは外部タグを出力しない（XSS等の防止）
    $c = trim((string)(setting_get('head_code', '') ?? ''));
    return $c === '' ? '' : "\n<!-- head: 管理画面で設定 -->\n" . $c . "\n";
}
function cms_body_code(): string {
    if (cms_demo_mode()) return ''; // デモモードは外部タグを出力しない
    $c = trim((string)(setting_get('body_code', '') ?? ''));
    return $c === '' ? '' : "\n<!-- body: 管理画面で設定 -->\n" . $c . "\n";
}

/** url()/page_url() の“パス”を、リクエストから scheme+host を補って絶対URLにする（OGP/正規URL用）。 */
function cms_abs_url(string $pathUrl): string {
    if ($pathUrl === '' || preg_match('#^https?://#i', $pathUrl)) return $pathUrl; // 既に絶対
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
        $scheme = trim(explode(',', (string)$_SERVER['HTTP_X_FORWARDED_PROTO'])[0]);
    }
    $host = (string)($_SERVER['HTTP_HOST'] ?? '');
    if ($host === '') return $pathUrl; // ホスト不明時は相対のまま（フォールバック）
    return $scheme . '://' . $host . $pathUrl;
}

/** ファビコンの<link>（設定 favicon。未設定なら空）。build_head_css から全フレーム共通で出力。 */
function cms_favicon_link(): string {
    $f = trim((string)(setting_get('favicon', '') ?? ''));
    if ($f === '') return '';
    $u = h(upload_url($f));
    return '<link rel="icon" href="' . $u . '">' . "\n"
         . '<link rel="apple-touch-icon" href="' . $u . '">' . "\n";
}

/** OGP / Twitterカード用メタ（SNSシェア時のタイトル・説明・サムネ画像）。ページ文脈で呼ぶ。 */
function cms_ogp_tags(string $title, string $desc, string $slug, bool $isHome, string $siteName): string {
    $url   = cms_abs_url(page_url($slug));
    $img   = trim((string)(setting_get('ogp_image', '') ?? ''));
    $imgUrl = $img !== '' ? cms_abs_url(upload_url($img)) : '';
    $tags = [
        ['property', 'og:type',        $isHome ? 'website' : 'article'],
        ['property', 'og:site_name',   $siteName],
        ['property', 'og:title',       $title],
        ['property', 'og:url',         $url],
        ['name',     'twitter:card',   $imgUrl !== '' ? 'summary_large_image' : 'summary'],
    ];
    if ($desc !== '')   $tags[] = ['property', 'og:description', $desc];
    if ($imgUrl !== '') $tags[] = ['property', 'og:image', $imgUrl];
    $out = '';
    foreach ($tags as [$attr, $key, $val]) {
        if ($val === '') continue;
        $out .= '<meta ' . $attr . '="' . $key . '" content="' . h($val) . '">' . "\n";
    }
    return $out;
}

function build_head_css(): string {
    // Tablerアイコン（パーツ・フッターSNS・アイコン選択の表示に必要。全フレーム共通で読み込む）
    // ※ assets/vendor/tabler-icons/ に同梱（CDN非依存・オフライン動作）
    $out  = '<link rel="stylesheet" href="' . h(url('/assets/vendor/tabler-icons/tabler-icons.min.css')) . '">' . "\n";
    $out .= '<link rel="stylesheet" href="' . h(url('/assets/common/inview.css')) . '">' . "\n";
    $files = glob(CMS_ROOT . '/assets/parts/*.css') ?: [];
    sort($files);
    $extra = '';
    foreach ($files as $f) {
        $name = basename($f);
        $link = '<link rel="stylesheet" href="' . h(url('/assets/parts/' . $name)) . '">' . "\n";
        if ($name === 'cms-extra.css') { $extra = $link; continue; }
        $out .= $link;
    }
    // プラグイン同梱CSS（plugins/<name>/assets/*.css を自動読込）
    foreach (glob(CMS_ROOT . '/plugins/*/assets/*.css') ?: [] as $f) {
        $rel = str_replace(CMS_ROOT, '', $f);
        $out .= '<link rel="stylesheet" href="' . h(url($rel)) . '">' . "\n";
    }
    // ファビコン（設定があれば）
    $out .= cms_favicon_link();
    // cms-extra を最後に、さらにテーマカラー上書き（あれば）を一番最後に
    return $out . $extra . build_theme_override();
}

/**
 * メイン画像のキャッチ・サブの文字色上書きCSSルール（1スコープ分）。
 * $mode: 'light'=白文字 / 'dark'=濃い文字。見出し(h1〜h3)とサブ段落だけ。ボタンは除外。
 */
function mv_text_color_rule(string $scope, string $mode): string {
    $sel = $scope . ' h1,' . $scope . ' h2,' . $scope . ' h3,' . $scope . ' h4,' . $scope . ' h5,' . $scope . ' h6,' . $scope . ' p:not(.btn)';
    if ($mode === 'light') {
        return $sel . '{color:#fff !important;text-shadow:0 1px 4px rgba(0,0,0,.55) !important;}';
    }
    return $sel . '{color:#222 !important;text-shadow:0 1px 3px rgba(255,255,255,.6) !important;}';
}

/**
 * メイン画像の文字色上書きを効かせる範囲（@media の条件）。
 * frame.json の mainvisual_text_overlay_media にそのまま書く。例：'(min-width:701px)'
 *
 * なぜ必要か：フレームによっては小さい端末でテキストを画像の外（下や上）へ段落ちさせる。
 * 外に出た先はページの背景色（各フレームとも #fff）なので、白文字のままだと読めない。
 * 「重なっている間だけ上書きする」＝外れたらフレーム本来の色に戻す、が正しい挙動。
 *
 * 空文字を返したら包まない（＝従来どおり無条件）。壊れた条件を書かれても
 * 半端な @media を出さない（許可文字だけ残す＋括弧の対応が取れなければ捨てる）。
 */
/**
 * ロゴの大きさ（倍率）。管理画面「サイト基本設定」で選ぶ。
 *
 * なぜ「倍率」で、px の直接指定にしないか：
 * フレームごとに既定のロゴ幅が 100px〜300px とバラバラ（portfolio2=100 / kagu1=120 / lp4=250 / frame4系=300 …）。
 * ここで px の選択肢（150/200/250…）を出すと、既定100px のフレームで「小さめ=150px」を選んだ人が
 * かえって大きくなる、という事故が起きる。倍率なら**どのフレームでも「小さめを選べば小さくなる」**が成立し、
 * frame9 系のようにヘッダー(200px)とトップの大きなロゴ(300px)を持つフレームでも**比率を保ったまま**動く。
 *
 * '' ＝ 標準＝何も出力しない＝フレーム本来の大きさ（メイン画像の文字色と同じ「触らなければフレーム任せ」の流儀）。
 */
function logo_scale_choices(): array {
    return [
        ''     => '標準（フレーム本来の大きさ）',
        '0.7'  => 'かなり小さめ（70%）',
        '0.85' => '小さめ（85%）',
        '1.2'  => '大きめ（120%）',
        '1.5'  => 'かなり大きめ（150%）',
    ];
}

function mv_text_overlay_media(array $meta): string {
    $raw = trim((string)($meta['mainvisual_text_overlay_media'] ?? ''));
    if ($raw === '') return '';
    // メディアクエリの条件に出てよい文字だけ通す（`{` `}` `;` `@` などが混ざると出力CSSが壊れるため）
    $safe = trim((string)preg_replace('/[^0-9a-zA-Z()\-:,. ]/', '', $raw));
    if ($safe === '' || strpos($safe, '(') === false) return '';        // 条件の形になっていない
    if (substr_count($safe, '(') !== substr_count($safe, ')')) return ''; // 括弧が閉じていない
    return $safe;
}

/* ============================================================
 *  テーマカラー（管理画面から上書き）
 *  - 主要色だけユーザーが選び、文字色（対色）は明るさから自動判定
 *  - <head>末尾に :root の上書きを差し込む（theme.cssに勝つ）
 * ============================================================ */

/** ユーザーが変更できる色（キー => ラベル・CSS変数・自動対色の変数） */
function theme_color_fields(): array {
    return [
        'primary' => ['label' => 'メインカラー',      'var' => '--primary-color', 'inverse' => '--primary-inverse-color'],
        'accent'  => ['label' => 'アクセントカラー',  'var' => '--accent-color',  'inverse' => '--accent-inverse-color'],
        'light'   => ['label' => '薄い色（差し色）',   'var' => '--light-color',   'inverse' => '--light-inverse-color'],
        'bg'      => ['label' => '背景色',            'var' => '--bg-color',      'inverse' => null],
        'text'    => ['label' => '文字色',            'var' => '--bg-inverse-color', 'inverse' => null],
    ];
}

/**
 * 「対の色（文字色）」で選べる値。'' ＝ 自動（明るさから判定）。
 * ※自動判定は万能ではない（コントラスト比の計算上は黒が有利でも、彩度の高い色の上では
 *   白の方が読みやすく綺麗に見えることが多い＝数値と人の目は一致しない）。
 *   なので「人が選べる」逃げ道を用意している。既定は '' なので従来と挙動は変わらない。
 */
function theme_inverse_choices(): array {
    return ['' => '自動', '#ffffff' => '白', '#333333' => '濃色'];
}

/**
 * 管理画面から入力された「フレームのCSS」を安全な形にする（上級者向け機能）。
 * ・`<` `>` を除去＝`</style>` でスタイルを閉じてタグを注入されるのを防ぐ（CSSに山括弧は不要）
 * ・`@charset` を除去＝`<style>` の中では無効なうえ、残っていると後続の `@import`
 *   （theme.css が Google Fonts の読み込みに使っている）が効かない恐れがあるため。
 *   ※`@import` は「他のルールより前」に無いと無効なので、この掃除が要る。
 */
function theme_css_sanitize(string $css): string {
    $css = str_replace(["\0", "<", ">"], '', $css);
    $css = preg_replace('/@charset\s+["\'][^"\']*["\']\s*;/i', '', $css);
    return trim((string)$css);
}

/** フレーム別に保存された「編集後のCSS」（無ければ ''）。キーに frameKey が入る＝他フレームへ持ち込まれない */
function frame_theme_css(string $frameKey): string {
    return theme_css_sanitize((string)(setting_get('theme_css:' . $frameKey, '') ?? ''));
}

/** そのフレームの CSS 原文（編集済みならそれ、無ければ frames/<key>/css/theme.css の中身） */
function frame_theme_source(string $frameKey): string {
    $custom = frame_theme_css($frameKey);
    if ($custom !== '') return $custom;
    return (string)@file_get_contents(CMS_FRAMES . '/' . $frameKey . '/css/theme.css');
}

/** 背景色に対して読みやすい文字色（白/黒）を明るさから自動判定 */
function contrast_text_color(string $hex): string {
    $hex = ltrim(trim($hex), '#');
    if (strlen($hex) === 3) $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
    if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return '#ffffff';
    $r = hexdec(substr($hex, 0, 2));
    $g = hexdec(substr($hex, 2, 2));
    $b = hexdec(substr($hex, 4, 2));
    $lum = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
    return $lum > 0.6 ? '#333333' : '#ffffff';
}

/** 現在有効なフレームの「実際の色」（theme.css既定＋ユーザー上書き）を解決して返す。
 *  キー：primary / accent / light / bg / text。管理画面の色チップをサイトと一致させる用。 */
function cms_active_colors(): array {
    static $cache = null;
    if ($cache !== null) return $cache;
    $cache = frame_theme_defaults(active_frame_key());
    $over = setting_get_json('theme_colors');
    foreach (['primary', 'accent', 'light', 'bg', 'text'] as $k) {
        if (!empty($over[$k]) && is_string($over[$k]) && preg_match('/^#[0-9a-fA-F]{3,6}$/', $over[$k])) {
            $cache[$k] = $over[$k];
        }
    }
    return $cache;
}

/**
 * そのフレームの「各色の既定値」（hex）を読む。
 * ★編集後のCSSがあればそちらを読む＝管理画面のパレット表示・差分保存の判定が、
 *   実際にサイトへ出ている色と一致する（直接編集とパレットが連動する要）。
 */
function frame_theme_defaults(string $frameKey): array {
    $css = frame_theme_source($frameKey);
    $out = [];
    foreach (theme_color_fields() as $key => $f) {
        if (preg_match('/' . preg_quote($f['var'], '/') . '\s*:\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/', $css, $m)) {
            $hx = $m[1];
            if (strlen($hx) === 3) $hx = $hx[0] . $hx[0] . $hx[1] . $hx[1] . $hx[2] . $hx[2];
            $out[$key] = '#' . strtolower($hx); // カラー入力用に6桁化
        }
    }
    return $out;
}

/** テーマカラー上書きの <style>（無ければ空） */
function build_theme_override(): string {
    $colors = setting_get_json('theme_colors');
    // 上級者向け：そのフレームのCSSを直接編集したもの（フレーム別）。
    // ★出力順は「編集CSS → パレット」。編集CSSはフレームの theme.css を置き換える“土台”で、
    //   パレットの5色は全フレーム共通のブランドカラーとして最後に上書きする、という関係にしている。
    $rawCss = frame_theme_css(active_frame_key());
    $lines = '';
    if ($colors) {
        $choices = theme_inverse_choices();
        foreach (theme_color_fields() as $key => $f) {
            $val = trim((string)($colors[$key] ?? ''));
            if ($val === '' || !preg_match('/^#[0-9a-fA-F]{3,6}$/', $val)) continue;
            $lines .= $f['var'] . ':' . $val . ';';
            if (!empty($f['inverse'])) {
                // 対の色は「自動」が既定。管理画面で白/濃色を選んでいればそれを優先する。
                $inv = (string)($colors[$key . '_inverse'] ?? '');
                if ($inv === '' || !isset($choices[$inv])) $inv = contrast_text_color($val);
                $lines .= $f['inverse'] . ':' . $inv . ';';
            }
        }
    }
    $out = '';
    // 先に編集CSS（フレームの theme.css を置き換えるもの。フォント・余白・枠線まで全部触れる）
    if ($rawCss !== '') $out .= '<style id="cms-theme-css">' . $rawCss . '</style>' . "\n";
    // 後からパレットの5色（全フレーム共通のブランドカラー）＝こちらが優先
    if ($lines !== '') $out .= '<style id="cms-theme-override">:root{' . $lines . '}</style>' . "\n";
    // ロゴの大きさ（倍率）。フレームの style.css が calc(var(--logo-width) * var(--logo-scale,1)) で読む。
    // 未設定（'）は何も出さない＝var() の既定値 1 が効く＝フレーム本来の大きさ。
    // 色とは別の変数なので、上の編集CSS・パレットとは衝突しない（上級者は編集CSSで --logo-width 自体を変えられる）。
    $logoScale = (string)(setting_get('logo_scale', '') ?? '');
    if ($logoScale !== '' && isset(logo_scale_choices()[$logoScale])) {
        $out .= '<style id="cms-logo-scale">:root{--logo-scale:' . $logoScale . ';}</style>' . "\n";
    }
    return $out;
}

/** 末尾用：設定注入＋共通JS＋パーツJS＋任意のframe.js */
function build_scripts(string $frameKey, array $meta): string {
    // フレームのメニュー設定（frame.json）をJSへ注入
    // ブレイクポイントは frame.json が標準値。管理画面（デザイン）で上書き指定があればそれを優先（安全な範囲のみ）。
    $breakPoint = (int)($meta['menu_breakpoint'] ?? 900);
    $bpOverride = (string)(setting_get('menu_breakpoint', '') ?? '');
    // menu_breakpoint_lock のフレームは管理画面の上書きを無視し、frame.json の値に固定（ハンバーガー専用フレーム等）
    if (empty($meta['menu_breakpoint_lock']) && in_array($bpOverride, ['700', '800', '900', '1000', '1100', '1200', '9999'], true)) {
        $breakPoint = (int)$bpOverride; // 9999＝常にハンバーガー（最初から開閉式にしたいフレーム用）
    }
    $opts = [
        'breakPoint'  => $breakPoint,
        'hideMenubar' => !empty($meta['menu_hide_when_hidden']),
        'keepHamburger' => !empty($meta['menu_keep_hamburger']), // PC幅でもハンバーガーを隠さない（常時ハンバーガー型）
        'dropdowns'   => array_values(array_filter((array)($meta['menu_dropdowns'] ?? []), 'is_string')),
    ];
    $out  = '<script>window.CMS_FRAME_OPTS=' . json_encode($opts) . ';</script>' . "\n";
    // jQuery（assets/vendor/jquery/ に同梱・CDN非依存）
    $out .= '<script src="' . h(url('/assets/vendor/jquery/jquery.min.js')) . '"></script>' . "\n";
    // スクロール表示演出（旧 inview プラグインは廃止。標準の IntersectionObserver で実装）
    $out .= '<script src="' . h(url('/assets/common/jquery.inview_set.js')) . '"></script>' . "\n";
    // 共通JS（メニュー・スクロール等）
    $out .= '<script src="' . h(url('/assets/common/common.js')) . '"></script>' . "\n";
    // パーツJS（自動）
    $jsFiles = glob(CMS_ROOT . '/assets/parts/*.js') ?: [];
    sort($jsFiles);
    foreach ($jsFiles as $f) {
        $out .= '<script src="' . h(url('/assets/parts/' . basename($f))) . '"></script>' . "\n";
    }
    // プラグイン同梱JS（plugins/<name>/assets/*.js を自動読込）
    foreach (glob(CMS_ROOT . '/plugins/*/assets/*.js') ?: [] as $f) {
        $out .= '<script src="' . h(url(str_replace(CMS_ROOT, '', $f))) . '"></script>' . "\n";
    }
    // フレーム固有JS（frames/<key>/js/main.js を優先、無ければ直下 main.js）
    if (is_file(CMS_FRAMES . '/' . $frameKey . '/js/main.js')) {
        $out .= '<script src="' . h(frame_asset_url($frameKey, 'js/main.js')) . '"></script>' . "\n";
    } elseif (is_file(CMS_FRAMES . '/' . $frameKey . '/main.js')) {
        $out .= '<script src="' . h(frame_asset_url($frameKey, 'main.js')) . '"></script>' . "\n";
    }
    return $out;
}

/* ============================================================
 *  ページ組み立て
 * ============================================================ */
function render_site_page(array $page): string {
    $slug    = $page['slug'];
    $content = render_blocks(blocks_of((int)$page['id']));
    $html = render_site_shell(
        $slug,
        (string)($page['title'] ?? ''),
        $content,
        (string)($page['meta_description'] ?? ''),
        $slug === 'home'
    );
    // 非公開ページを管理者が見ている時は、本人にだけ「非公開」バーを出す（一般訪問者には出ない＝上で404）
    if (($page['status'] ?? 'public') === 'draft' && !empty($_SESSION['admin_id'])) {
        $html = str_replace('</body>', build_draft_bar() . '</body>', $html);
    }
    return $html;
}

/** 非公開ページ閲覧中バー（ログイン管理者のみ表示される文脈で呼ぶ） */
function build_draft_bar(): string {
    $css = '#cms-draft-bar{position:fixed;left:0;right:0;bottom:0;z-index:99999;background:#9a3412;color:#fff;'
         . 'font-size:14px;line-height:1.4;padding:10px 16px;text-align:center;'
         . 'font-family:system-ui,sans-serif;box-shadow:0 -2px 10px rgba(0,0,0,.3)}'
         . '#cms-draft-bar strong{color:#fde68a}';
    return '<style>' . $css . '</style>'
         . '<div id="cms-draft-bar">このページは<strong>非公開</strong>です。一般の訪問者には表示されません（ログイン中のあなたにだけ見えています）。</div>';
}

/**
 * 任意のコンテンツを「現在のフレームのガワ」で包んでフルページHTMLにする。
 *  - 通常ページ（render_site_page）も、プラグインの詳細ページも、ここを通す。
 *  - これにより一覧・詳細プラグイン等が、フレーム着せ替えにそのまま乗れる。
 *
 * @param string $slug            BODY_CLASS / 現在ページ判定に使うslug（例 'news'）
 * @param string $title           ページタイトル（<title> や PAGE_TITLE）
 * @param string $contentHtml     本文HTML（各ブロックの描画結果など）
 * @param string $metaDescription メタディスクリプション
 * @param bool   $isHome          ホーム扱い（メイン画像を出す）か
 */
function render_site_shell(string $slug, string $title, string $contentHtml, string $metaDescription = '', bool $isHome = false): string {
    $frameKey = active_frame_key();
    $meta = frame_all()[$frameKey] ?? [];
    $layout = (string)@file_get_contents(CMS_FRAMES . '/' . $frameKey . '/layout.html');
    if ($layout === '') return '<!doctype html><meta charset="utf-8"><p>フレームが見つかりません。</p>';

    $mvHtml = '';
    if ($isHome) {
        $mvHtml = render_mainvisual(setting_get_json('mainvisual'), $meta, $frameKey);
    }

    $siteName  = setting_get('site_name', 'My Site') ?? 'My Site';
    $copyright = setting_get('copyright', $siteName) ?? $siteName;

    // ロゴ：通常ロゴ(logo)／メイン画像の上用ロゴ(logo_hero)のどちらか一方でもあれば画像表示、両方なければサイト名テキスト。
    // フレームは透過ヘッダー（メイン画像に重なる状態）で {{LOGO_HERO_URL}}、固定ヘッダーで {{LOGO_URL}} を使い分ける。
    $logo        = setting_get('logo', '') ?? '';
    $logoHero    = setting_get('logo_hero', '') ?? '';
    $hasLogo     = ($logo !== '' || $logoHero !== '');                 // どちらか一方でもあれば画像ロゴ扱い
    $hasLogoHero = $logoHero !== '';                                    // 専用の上用ロゴが設定されているか
    // 通常用URL：logo を優先、無ければ上用ロゴにフォールバック（上用だけ登録された場合も表示される）
    $logoUrl     = $logo !== '' ? upload_url($logo) : ($logoHero !== '' ? upload_url($logoHero) : '');
    // 上用URL：logo_hero を優先、無ければ通常ロゴにフォールバック
    $logoHeroUrl = $logoHero !== '' ? upload_url($logoHero) : $logoUrl;

    $metaTitle = $isHome ? $siteName
        : ($title !== '' ? $title . ' | ' . $siteName : $siteName);

    $credit = cms_footer_credit();

    $ctx = [
        'BASE'             => BASE_URL,
        'FRAME'            => $frameKey,
        'BODY_CLASS'       => $isHome ? 'home' : ('page-' . $slug),
        'SITE_NAME'        => $siteName,
        'HAS_LOGO'         => $hasLogo,
        'LOGO_URL'         => $logoUrl,
        'HAS_LOGO_HERO'    => $hasLogoHero, // 専用の「メイン画像の上用ロゴ」が設定されているか
        'LOGO_HERO_URL'    => $logoHeroUrl, // 未設定時は通常ロゴURLにフォールバック
        'HOME_URL'         => page_url('home'),
        'NAV'              => build_nav($slug),            // 単一メニュー型フレーム用（CSSで出し分け）
        'NAV_PC'           => build_nav($slug, 'pc'),      // 2メニュー型のPCヘッダー用（PC表示ONのみ）
        'NAV_MOBILE'       => build_nav($slug, 'mobile'),  // 2メニュー型のスマホ#menubar用（スマホ表示ONのみ）
        'IS_HOME'          => $isHome,
        'HAS_MAINVISUAL'   => $mvHtml !== '',
        'MAINVISUAL'       => $mvHtml,
        'PAGE_TITLE'       => $title,
        'CONTENT'          => $contentHtml,
        'COPYRIGHT'        => $copyright,
        'YEAR'             => date('Y'),
        'META_TITLE'       => $metaTitle,
        'META_DESCRIPTION' => $metaDescription,
        'CREDIT'           => $credit,
        'FOOTER'           => render_footer($copyright, date('Y')),
        'HEAD_CSS'         => build_head_css()
                              . cms_ogp_tags($metaTitle, $metaDescription, $slug, $isHome, $siteName) // SNSシェア用メタ
                              . cms_head_code(),    // ＋管理画面で貼った<head>用コード（GA等）
        'SCRIPTS'          => build_scripts($frameKey, $meta) . cms_body_code(), // ＋</body>直前用コード
    ];
    // フレーム固有素材（ASSET_<key> / HAS_ASSET_<key>）
    $ctx += frame_asset_ctx($frameKey, $meta);

    // 管理者プレビュー中なら、適用バーを差し込む
    if (frame_preview_key() !== '') {
        $ctx['SCRIPTS'] = build_preview_bar($frameKey, $meta, $slug) . $ctx['SCRIPTS'];
    }

    $html = render_template($layout, $ctx);
    // 製品版の発行シリアル（目視照合用コメント）を </body> 直前に置く。無料版では空＝何も足さない。
    $serial = cms_serial_comment();
    if ($serial !== '') $html = str_replace('</body>', $serial . '</body>', $html);
    return $html;
}

/** プレビュー中バー（このフレームを適用／やめる）。ログイン管理者のみ表示される文脈で呼ぶ */
function build_preview_bar(string $frameKey, array $meta, string $slug): string {
    $name = (string)($meta['name'] ?? $frameKey);
    $applyUrl = url('/admin/design.php');
    $stopUrl  = page_url($slug);
    $css = '#cms-preview-bar{position:fixed;left:0;right:0;bottom:0;z-index:99999;'
         . 'background:#111827;color:#fff;font-size:14px;line-height:1.4;'
         . 'padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;'
         . 'font-family:system-ui,sans-serif;box-shadow:0 -2px 10px rgba(0,0,0,.3)}'
         . '#cms-preview-bar strong{color:#fde68a}'
         . '#cms-preview-bar .pv-btn{background:#2563eb;color:#fff;border:0;border-radius:6px;'
         . 'padding:6px 14px;cursor:pointer;font-size:14px}'
         . '#cms-preview-bar a{color:#cbd5e1;text-decoration:underline}';
    $h  = '<style>' . $css . '</style>';
    $h .= '<div id="cms-preview-bar">';
    $h .= '<span>プレビュー中：<strong>' . h($name) . '</strong>（まだ公開サイトには反映されていません）</span>';
    $h .= '<form method="post" action="' . h($applyUrl) . '" style="margin:0">';
    $h .= '<input type="hidden" name="_csrf" value="' . h(csrf_token()) . '">';
    $h .= '<input type="hidden" name="action" value="set_frame">';
    $h .= '<input type="hidden" name="frame" value="' . h($frameKey) . '">';
    $h .= '<button type="submit" class="pv-btn">このフレームを適用する</button>';
    $h .= '</form>';
    $h .= '<a href="' . h($stopUrl) . '">やめる</a>';
    $h .= '</div>';
    return $h;
}

/* ============================================================
 *  アップロード画像/動画のお掃除（未使用ファイルの検出・削除）
 *  方針：
 *   - 「未使用」＝そのファイル名が **どこからも参照されていない**こと。
 *     参照走査の対象は DB全体（blocks/settings/list_items/pro_items）＋**コード側**（seed の core/db.php と
 *     各パーツの template.html）。コード側も含めることで、**同梱デモ/既定素材（ph7.jpg 等）は常に保護**される
 *     （これらの名前はseedやテンプレに必ず登場するため）。
 *   - 名前の形では判定しない（手で置いた・リネームした・ローカル/サーバー差異で残った等、どんな名前でも検出できる）。
 *   - 拡張子は CMS が扱うメディア（画像/動画/PDF）に限定し、.htaccess 等のシステムファイルは触らない。
 * ============================================================ */

/** 掃除対象にしてよい拡張子か（CMSがアップロードしうるメディアのみ） */
function cms_upload_is_media(string $name): bool {
    return (bool)preg_match('/\.(jpe?g|png|webp|gif|svg|mp4|webm|pdf|ico)$/i', $name);
}

/** 文字列（JSON等）から、アップロードファイル名トークンを全部抜き出す（自動掃除で削除前に控える用） */
function cms_extract_upload_names(string $text): array {
    if ($text === '') return [];
    // admin_save_upload の命名（日時_8hex.ext）を抽出
    preg_match_all('/\d{8}_\d{6}_[0-9a-f]{8}\.[A-Za-z0-9]+/', $text, $m);
    return array_values(array_unique($m[0]));
}

/** 参照走査用の「干し草の山」。コード側（不変）はキャッシュ、DB側は毎回フレッシュ（削除直後の判定用）。 */
function cms_upload_reference_blob(): string {
    static $code = null;
    if ($code === null) {
        // 同梱デモ/既定素材の参照元＝seed（core/db.php・各プラグインのPHP）と パーツテンプレ。
        // ここに出る名前は常に保護される（fresh install やプラグインのデモ素材も誤検出しない）。
        $code = (string)@file_get_contents(CMS_CORE . '/db.php');
        foreach (glob(CMS_ROOT . '/parts/*/template.html') ?: [] as $t) {
            $code .= "\n" . (string)@file_get_contents($t);
        }
        foreach (glob(CMS_ROOT . '/plugins/*/*.php') ?: [] as $p) {
            $code .= "\n" . (string)@file_get_contents($p);
        }
    }
    $parts = [];
    $pull = function (string $sql) use (&$parts) {
        try {
            foreach (db()->query($sql) as $row) {
                foreach ($row as $v) { if (is_string($v) && $v !== '') $parts[] = $v; }
            }
        } catch (Throwable $e) { /* テーブル未作成（プラグイン未使用）等は無視 */ }
    };
    $pull("SELECT data FROM blocks");
    $pull("SELECT value FROM settings");
    $pull("SELECT title,body,image FROM list_items");
    $pull("SELECT thumb,data FROM pro_items");
    return $code . "\n" . implode("\n", $parts);
}

/** そのファイル名がどこか（DB or コード）で参照されているか（blob未指定なら毎回作る＝削除直後のチェック用） */
function cms_upload_is_referenced(string $name, ?string $blob = null): bool {
    if ($name === '') return false;
    if ($blob === null) $blob = cms_upload_reference_blob();
    // ファイル名“まるごと一致”で判定する。
    // 単純な部分一致(strpos)だと、apt1.jpg が lpdemo-apt1.jpg の一部に誤ヒットして
    // 「使用中」と誤判定され、不要ファイルが掃除に出てこない不具合があった（2026-06-27修正）。
    // 直前がファイル名構成文字（英数 _ . -）でなく、直後が英数でない出現だけを一致とみなす。
    return (bool)preg_match('/(?<![A-Za-z0-9_.\-])' . preg_quote($name, '/') . '(?![A-Za-z0-9])/', $blob);
}

/** uploads内の「どこからも未参照」のメディアファイル一覧（掃除候補）。[{name,size,mtime,is_video}] */
function cms_orphan_uploads(): array {
    $dir = CMS_UPLOADS;
    if (!is_dir($dir)) return [];
    $blob = cms_upload_reference_blob();
    $out = [];
    foreach (scandir($dir) ?: [] as $f) {
        if ($f === '' || $f[0] === '.') continue;          // . .. .htaccess 等は対象外
        if (!cms_upload_is_media($f)) continue;             // 画像/動画/PDF以外は触らない
        if (cms_upload_is_referenced($f, $blob)) continue;  // 参照あり（使用中・デモ素材）は除外
        $path = $dir . '/' . $f;
        if (!is_file($path)) continue;
        $out[] = [
            'name'     => $f,
            'size'     => (int)@filesize($path),
            'mtime'    => (int)@filemtime($path),
            'is_video' => (bool)preg_match('/\.(mp4|webm)$/i', $f),
        ];
    }
    usort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']); // 新しい順
    return $out;
}

/**
 * 指定ファイル群のうち「未参照のメディア」のものだけ物理削除する。
 * 削除処理の直後（DBから対象行が消えた状態）に呼ぶ→参照チェックで安全に消える。戻り値＝削除数。
 * ※自動掃除・手動掃除の両方から使う共通の安全ゲート。
 */
function cms_cleanup_files_if_unused(array $names): int {
    $names = array_values(array_unique(array_map('basename', array_filter($names, 'strlen'))));
    if (!$names) return 0;
    $blob = cms_upload_reference_blob();
    $n = 0;
    foreach ($names as $name) {
        if ($name === '' || $name[0] === '.') continue;
        if (!cms_upload_is_media($name)) continue;
        if (cms_upload_is_referenced($name, $blob)) continue;
        $path = CMS_UPLOADS . '/' . $name;
        if (is_file($path) && @unlink($path)) $n++;
    }
    return $n;
}

/* ============================================================
 *  プラグインの読み込み（このファイルの最後で・1回だけ）
 *  - ここまでで config / db / parts / frame の関数は全て定義済み。
 *  - plugins/<name>/plugin.php が cms_register_* でフックを登録する。
 *  - 以降、初回の db() 実行時に db_migrate がプラグインのテーブルも作る。
 * ============================================================ */
cms_load_plugins();
