<?php
declare(strict_types=1);

/**
 * app/macro.php
 * テンプレHTML内部の {{…}} マクロをフロント出力に差し替える。
 *
 * 対応マクロ（LD-06）:
 * - {{SEARCH_FORM id="basic"}}
 * - {{LIST card="A"}}
 * - {{PAGER}}
 * - {{DETAIL gallery="wide"}}
 * - {{MAP slot="main"}}   ※ map_iframe の値をそのまま出力（将来APIに差替可）
 *
 * 使い方（例）：
 *   require_once __DIR__.'/front.php';
 *   require_once __DIR__.'/macro.php';
 *   $html = file_get_contents(__DIR__.'/../public/list_template.html');
 *   echo macro_render($html, ['category'=>$_GET['category'] ?? '', 'query'=>$_GET]);
 */

require_once __DIR__ . '/front.php';
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../security/_csrf.php';
require_once __DIR__ . '/categories.php';

/** 直近の一覧ペイジャを保持して {{PAGER}} で利用する */
static $__macro_last_pager = null;

/** BASE_PATH を返す（/ のときは空文字） */
function macro_base_path(array $ctx, array $attrs): string {
    return (defined('BASE_PATH') && BASE_PATH !== '/' && BASE_PATH !== '') ? BASE_PATH : '';
}

/** 簡易属性パーサ（key="val" のみ） */
function macro_parse_attrs(string $attrStr): array {
    $out = [];
    if (preg_match_all('/([A-Za-z0-9_:-]+)\s*=\s*"([^"]*)"/u', $attrStr, $m, PREG_SET_ORDER)) {
        foreach ($m as $one) {
            $out[$one[1]] = $one[2];
        }
    }
    return $out;
}

/** 内部：/templates/_search.form.html を読み込み（無ければ空文字） */
function macro__load_search_tpl(): string {
  $path = tpcms_templates_dir() . '/_search.form.html';
  if (is_file($path)) {
    $s = (string)@file_get_contents($path);
    return is_string($s) ? $s : '';
  }
  return '';
}

/** 内部：/templates/_contact.form.html を読み込み（無ければ空文字） */
function macro__load_contact_tpl(): string {
  $path = tpcms_templates_dir() . '/_contact.form.html';
  if (is_file($path)) {
    $s = (string)@file_get_contents($path);
    return is_string($s) ? $s : '';
  }
  return '';
}

/** 検索フォーム：/list へ GET 送信（category は hidden） */
function macro_search_form(array $ctx, array $attrs): string {
  $cat    = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  $q      = isset($ctx['query']['q']) ? (string)$ctx['query']['q'] : '';
  $action = url_for('/list');

  // 追加パラメータ（値保持）
  $sort = (string)($ctx['query']['sort'] ?? 'id');
  if (!in_array($sort, ['id','created_at','updated_at'], true)) $sort = 'id';

  $dir = strtolower((string)($ctx['query']['dir'] ?? 'desc'));
  $dir = ($dir === 'asc') ? 'asc' : 'desc';

  $per = (int)($ctx['query']['per'] ?? 20);
  if (!in_array($per, [20,50,100], true)) $per = 20;

  // 外部テンプレがあれば優先（プレースホルダーを展開）
  $tpl = macro__load_search_tpl();
  if ($tpl !== '') {
    $out = $tpl;

    // 基本置換
    $out = str_replace('{{action}}', h($action), $out);
    $out = str_replace('{{q}}', h($q), $out);

    // カテゴリ入力：ホーム等でカテゴリ未指定なら<select>を表示、指定ありならhidden
    if ($cat !== '') {
      $catInput = '<input type="hidden" name="category" value="'.h($cat).'">';
    } else {
      // 公開中カテゴリのセレクト
      $selCat = preg_replace('~[^A-Za-z0-9_\-]+~','', (string)($ctx['query']['category'] ?? ''));
      $opts = '<option value="" disabled'.($selCat===''?' selected':'').'>カテゴリを選択</option>';
      foreach (tpcms_cat_all(true) as $c) {
        $slug = (string)($c['slug'] ?? '');
        $name = (string)($c['name'] ?? $slug);
        if ($slug==='') continue;
        $opts .= '<option value="'.h($slug).'"'.($selCat===$slug?' selected':'').'>'.h($name).'</option>';
      }
      $catInput = '<label class="sr-only" for="category">カテゴリ</label><select id="category" name="category">'.$opts.'</select>';
    }
    $out = str_replace('{{category_input}}', $catInput, $out);

    // 新規：sort/dir/per の値保持
    $out = str_replace('{{sort}}', h($sort), $out);
    $out = str_replace('{{dir}}',  h($dir),  $out);
    $out = str_replace('{{per}}',  (string)$per, $out);

    // option の selected 展開
    $repl = [
      '{{sel_sort_id}}'         => $sort==='id' ? 'selected' : '',
      '{{sel_sort_created_at}}' => $sort==='created_at' ? 'selected' : '',
      '{{sel_sort_updated_at}}' => $sort==='updated_at' ? 'selected' : '',
      '{{sel_dir_asc}}'         => $dir==='asc' ? 'selected' : '',
      '{{sel_dir_desc}}'        => $dir==='desc' ? 'selected' : '',
      '{{sel_per_20}}'          => $per===20 ? 'selected' : '',
      '{{sel_per_50}}'          => $per===50 ? 'selected' : '',
      '{{sel_per_100}}'         => $per===100 ? 'selected' : '',
    ];
    $out = strtr($out, $repl);

    // ---- 追加：カテゴリ固有フィルタ（select/radio/checkbox）を自動生成 ----
    $filtersHtml = '';
    $currentCat = ($cat !== '') ? $cat : preg_replace('~[^A-Za-z0-9_\-]+~','', (string)($ctx['query']['category'] ?? ''));
    if ($currentCat !== '') {
      try {
        $pdo = db();
        $st = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $st->execute([$currentCat]);
        $cid = (int)$st->fetchColumn();
        if ($cid > 0) {
          $stF = $pdo->prepare('SELECT "key", "label", "type", "options" FROM fields WHERE category_id = ? AND searchable = 1 AND type IN ("select","radio","checkbox") ORDER BY ord ASC, id ASC');
          $stF->execute([$cid]);

          $qf = is_array($ctx['query']['f'] ?? null) ? $ctx['query']['f'] : [];
          $filters = [];

          foreach ($stF->fetchAll(PDO::FETCH_ASSOC) ?: [] as $frow) {
            $fkey   = (string)($frow['key']   ?? '');
            $flabel = (string)($frow['label'] ?? $fkey);
            $ftype  = (string)($frow['type']  ?? '');
            $optsJ  = (string)($frow['options'] ?? '');
            if ($fkey === '' || $optsJ === '') continue;

            $opts = json_decode($optsJ, true);
            if (!is_array($opts) || empty($opts)) continue;

            // 既定値（保持）
            $selVals = [];
            if (isset($qf[$fkey])) {
              if (is_array($qf[$fkey])) {
                foreach ($qf[$fkey] as $v) { $v = (string)$v; if ($v !== '') $selVals[$v] = true; }
              } else {
                $v = (string)$qf[$fkey];
                if ($v !== '') $selVals[$v] = true;
              }
            }

            $idBase = 'f_' . preg_replace('~[^A-Za-z0-9_\-]+~','', $fkey);
            $html = '<fieldset class="tpcms-filter"><legend>'.h($flabel).'</legend>';

            if ($ftype === 'select' || $ftype === 'radio') {
              // 単一選択：省スペースのため select で統一
              $html .= '<label class="sr-only" for="'.$idBase.'">'.h($flabel).'</label>';
              $html .= '<select id="'.$idBase.'" name="f['.h($fkey).']">';
              $html .= '<option value="">指定なし</option>';
              foreach ($opts as $o) {
                $v = isset($o['value']) ? (string)$o['value'] : '';
                $l = isset($o['label']) ? (string)$o['label'] : $v;
                if ($v === '') continue;
                $sel = isset($selVals[$v]) ? ' selected' : '';
                $html .= '<option value="'.h($v).'"'.$sel.'>'.h($l).'</option>';
              }
              $html .= '</select>';

            } elseif ($ftype === 'checkbox') {
              // 複数選択：チェックボックス
              foreach ($opts as $idx => $o) {
                $v = isset($o['value']) ? (string)$o['value'] : '';
                $l = isset($o['label']) ? (string)$o['label'] : $v;
                if ($v === '') continue;
                $id  = $idBase.'_'.$idx;
                $chk = isset($selVals[$v]) ? ' checked' : '';
                $html .= '<label><input type="checkbox" id="'.$id.'" name="f['.h($fkey).'][]" value="'.h($v).'"'.$chk.'> '.h($l).'</label> ';
              }
            }

            $html .= '</fieldset>';
            $filters[] = $html;
          }

          if (!empty($filters)) {
            $filtersHtml = '<div class="tpcms-filters">'.implode('', $filters).'</div>';
          }
        }
      } catch (Throwable $e) {
        // 失敗時は非表示
      }
    }

    // テンプレ内の {{filters}} を置換（無ければ何もしない）
    $out = str_replace('{{filters}}', $filtersHtml, $out);

    // ---- 追加：検索結果件数の表示（フォーム直下） ----
    $resultCountHtml = '';
    $currentCat2 = ($cat !== '') ? $cat : preg_replace('~[^A-Za-z0-9_\-]+~','', (string)($ctx['query']['category'] ?? ''));
    if ($currentCat2 !== '') {
      try {
        $resPager = front_list($currentCat2, is_array($ctx['query'] ?? null) ? $ctx['query'] : []);
        $totalHit = (int)($resPager['pager']['total'] ?? 0);
        $resultCountHtml = '<p class="tpcms-result-count">'.(string)$totalHit.'件ヒット</p>';
      } catch (Throwable $e) {
        // 失敗時は非表示
      }
    }
    $out .= $resultCountHtml;

    // {{BASE_PATH}} も展開可
    if (function_exists('macro_base_path')) {
      $out = str_replace('{{BASE_PATH}}', macro_base_path([], []), $out);
    }
    return $out;
  }

  // フォールバック（最小フォーム）
  if ($cat !== '') {
    $catInput = '<input type="hidden" name="category" value="'.h($cat).'">';
  } else {
    $selCat = preg_replace('~[^A-Za-z0-9_\-]+~','', (string)($ctx['query']['category'] ?? ''));
    $opts = '<option value="" disabled'.($selCat===''?' selected':'').'>カテゴリを選択</option>';
    foreach (tpcms_cat_all(true) as $c) {
      $slug = (string)($c['slug'] ?? '');
      $name = (string)($c['name'] ?? $slug);
      if ($slug==='') continue;
      $opts .= '<option value="'.h($slug).'"'.($selCat===$slug?' selected':'').'>'.h($name).'</option>';
    }
    $catInput = '<label class="sr-only" for="category">カテゴリ</label><select id="category" name="category">'.$opts.'</select>';
  }
  return <<<HTML
  <form class="tpcms-search" method="get" action="{$action}">
    {$catInput}
    <input type="search" name="q" value="{$q}" placeholder="キーワードで探す">
    <button type="submit">検索</button>
  </form>
  HTML;
}

/** 内部：/templates/_list.item.html を読み込み（無ければ空文字） */
function macro__load_list_item_tpl(?string $slug = null): string {
  $base = rtrim(tpcms_templates_dir(), '/') . '/';
  $candidates = [];
  if (is_string($slug) && $slug !== '') {
    $safe = preg_replace('~[^A-Za-z0-9_\-]+~', '', $slug);
    if ($safe !== '') {
      $candidates[] = $base . '_list.item.' . $safe . '.html'; // 例）_list.item.chintai.html
    }
  }
  $candidates[] = $base . '_list.item.html'; // 汎用フォールバック

  foreach ($candidates as $path) {
    if (is_file($path)) {
      $s = (string)@file_get_contents($path);
      return is_string($s) ? $s : '';
    }
  }
  return '';
}

// 内部：/templates/_home.{name}.html を読み込み（無ければ空文字）
function macro__load_home_section_tpl(string $name): string {
  $path = tpcms_templates_dir() . '/_home.' . $name . '.html';
  return is_file($path) ? (string)@file_get_contents($path) : '';
}

/** 内部：お知らせの URL を解決（https?:// は外部、detail?/list? は自動で url_for に変換） */
function macro__news_resolve_url(string $s): string {
  $s = trim($s);
  if ($s === '') return '';
  if (preg_match('~^https?://~i', $s)) return $s; // 外部

  // 短縮書式：detail?..., list?...
  if (preg_match('~^(detail|list)\?(.*)$~i', $s, $m)) {
    parse_str((string)$m[2], $qs);
    $qs2 = [];
    foreach ($qs as $k => $v) {
      if (is_scalar($v)) $qs2[(string)$k] = (string)$v;
    }
    $path = (strtolower((string)$m[1]) === 'detail') ? '/detail' : '/list';
    return url_for($path, $qs2);
  }

  // 先頭スラッシュはそのまま site ルート扱いに
  if ($s[0] === '/') return url_for($s);

  // それ以外は相対リンクを許容
  return $s;
}

/** NEWS：settings.NEWS_LIMIT（既定3）件を /templates/_home.news.html でラップして出力（無ければ既定マークアップ） */
function macro_news(array $ctx, array $attrs): string {
  // 件数： attr n="5" があれば優先、無ければ settings → 既定3
  $limit = isset($attrs['n']) ? max(1, (int)$attrs['n']) : (int)tpcms_setting('NEWS_LIMIT', '3');
  if ($limit <= 0) $limit = 3;

  // 取得
  $rows = [];
  try {
    $pdo = db();
    $st = $pdo->prepare('SELECT date, title, url FROM news ORDER BY date DESC, id DESC LIMIT :lim');
    $st->bindValue(':lim', $limit, PDO::PARAM_INT);
    $st->execute();
    $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
  } catch (Throwable $e) {
    return ''; // 失敗時は静かに無表示
  }
  if (!$rows) return '';

  // アイテムHTML（<li>…）
  $items = '';
  foreach ($rows as $r) {
    $date  = h((string)($r['date']  ?? ''));
    $title = h((string)($r['title'] ?? ''));
    $url   = (string)($r['url']    ?? '');

    $titleHtml = $title;
    if ($url !== '') {
      $href = macro__news_resolve_url($url);
      if ($href !== '') $titleHtml = '<a href="'.h($href).'">'.$title.'</a>';
    }
    $items .= '<dt>'.$date.'</dt><dd>'.$titleHtml.'</dd>';
  }

  // 任意ラッパ：/templates/_home.news.html … {{items}} を差し込み
  $wrap = macro__load_home_section_tpl('news');
  if ($wrap !== '') {
    if (function_exists('macro_base_path')) {
      $wrap = str_replace('{{BASE_PATH}}', macro_base_path([], []), $wrap);
    }
    return str_replace('{{items}}', $items, $wrap);
  }

  // 既定ラッパ（最小）
  return $items;
}

// 内部：/templates/_detail.{name}.html を読み込み（無ければ空文字）
function macro__load_detail_section_tpl(string $name): string {
  $path = tpcms_templates_dir() . '/_detail.' . $name . '.html';
  return is_file($path) ? (string)@file_get_contents($path) : '';
}

/** 内部：テンプレの簡易IF
 *  <!-- TPCMS:IF key --> ... [<!-- TPCMS:ELSE --> ...] <!-- /TPCMS:IF -->
 *  例）key = media / asset_url / title / values.rent など
 */
function macro__tpl_if(string $tpl, array $map, array $vals): string {
  $resolve = function (string $key) use ($map, $vals): string {
    if (str_starts_with($key, 'values.')) {
      $k = substr($key, 7);
      return (string)($vals[$k] ?? '');
    }
    return (string)($map[$key] ?? '');
  };

  return preg_replace_callback(
    '/<!--\s*TPCMS:IF\s+([A-Za-z0-9_.]+)\s*-->(.*?)'
    .'(?:<!--\s*TPCMS:ELSE\s*-->(.*?))?'
    .'<!--\s*\/TPCMS:IF\s*-->/us',
    function ($m) use ($resolve) {
      $key  = (string)$m[1];
      $then = (string)$m[2];
      $else = isset($m[3]) ? (string)$m[3] : '';
      $val  = trim($resolve($key));
      return ($val !== '') ? $then : $else;
    },
    $tpl
  );
}

/** 内部：1件テンプレを埋め込み（{{id}}, {{title}}, {{body:120}}, {{asset_url}}, {{media}}, {{detail_url}}, {{values.KEY}}） */
function macro__render_list_item(array $item, string $tpl, string $category): string {
  // まず items.asset_file（従来）を試す
  $asset = (string)($item['asset_url'] ?? '');

  // 空なら、カテゴリの assets フィールドの「並び順（ord ASC, id ASC）」に従って最初の値を使う
  if ($asset === '') {
    $vals = (array)($item['values'] ?? []);

    // カテゴリごとの assetsキー配列をキャッシュ
    static $ASSET_KEYS_CACHE = []; // ['slug' => ['key1','key2',...]]
    $keys = $ASSET_KEYS_CACHE[$category] ?? null;

    if (!is_array($keys)) {
      try {
        $pdo = db();
        // slug → category_id
        $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $stCid->execute([(string)$category]);
        $cid = (int)$stCid->fetchColumn();

        $keys = [];
        if ($cid > 0) {
          $stK = $pdo->prepare('SELECT "key" FROM fields WHERE category_id = ? AND type = "assets" ORDER BY ord ASC, id ASC');
          $stK->execute([$cid]);
          foreach ($stK->fetchAll(PDO::FETCH_COLUMN) ?: [] as $k) {
            $k = (string)$k;
            if ($k !== '') $keys[] = $k;
          }
        }
      } catch (Throwable $e) {
        $keys = [];
      }
      $ASSET_KEYS_CACHE[$category] = $keys;
    }

    // 並び順1番目から順に、値が入っている最初のファイルを採用
    if (!empty($keys)) {
      foreach ($keys as $akey) {
        $fn = (string)($vals[$akey] ?? '');
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          $asset = asset_url('/uploads/' . $fn, false);
          break;
        }
      }
    }

    // それでも空なら、values の中から“それっぽい拡張子”を持つ最初の値をフォールバック採用
    if ($asset === '' && !empty($vals)) {
      foreach ($vals as $v) {
        $fn = (string)$v;
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          $asset = asset_url('/uploads/' . $fn, false);
          break;
        }
      }
    }
  }

  // <img> / <video> を自動生成
  $media = '';
  if ($asset !== '') {
    $ext2 = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
    if (in_array($ext2, ['jpg','jpeg','png','gif','webp'], true)) {
      $media = '<img src="'.h($asset).'" alt="">';
    } elseif (in_array($ext2, ['mp4','webm','mov'], true)) {
      $media = '<video src="'.h($asset).'" playsinline controls></video>';
    }
  }

  $map = [
    'id'         => (string)($item['id'] ?? ''),
    'category'   => (string)($item['category'] ?? ''),
    'title'      => (string)($item['title'] ?? ''),
    'body'       => (string)($item['body'] ?? ''),
    'asset_url'  => $asset,           // 並び順に基づくURL
    'created_at' => (string)($item['created_at'] ?? ''),
    'updated_at' => (string)($item['updated_at'] ?? ''),
    'media'      => $media,           // 生HTML
    'detail_url' => url_for('/detail', ['category' => (string)$category, 'id' => (int)($item['id'] ?? 0)]),
  ];
  $vals = (array)($item['values'] ?? []);

  // 簡易IF（TPCMS:IF）を先に評価
  $tpl2 = macro__tpl_if((string)$tpl, $map, $vals);

  // カテゴリに応じた「value→label」変換マップ
  $labelsMap = macro__labels_map((string)$category);
  $typeMap   = macro__types_map((string)$category);

  // プレースホルダー展開（values.KEY は可能なら label を表示）
$out = preg_replace_callback('/\{\{\s*([A-Za-z0-9_.]+)(?::(\d+))?\s*\}\}/u', function($m) use ($map, $vals, $labelsMap, $typeMap) {
  $key = (string)$m[1];
  $len = isset($m[2]) ? max(0, (int)$m[2]) : null;

  // NEW/UP バッジ
  if ($key === 'NEW_BADGE' || $key === 'UP_BADGE') {
    $days = ($len !== null)
      ? $len
      : (int)( $key === 'NEW_BADGE'
            ? tpcms_setting('NEW_DAYS', '7')
            : tpcms_setting('UP_DAYS',  '7') );
    $createdAt = (string)($map['created_at'] ?? '');
    $updatedAt = (string)($map['updated_at'] ?? '');
    $type = ($key === 'NEW_BADGE') ? 'new' : 'up';
    return macro__badge_html($type, max(0, (int)$days), $createdAt, $updatedAt);
  }

  // values.KEY（select/radio/checkboxはラベル化、textareaは <br> 反映）
  if (strpos($key, 'values.') === 0) {
    $k   = substr($key, 7);
    $raw = (string)($vals[$k] ?? '');
    if ($raw === '') return '';

    // ラベル化（選択系）
    if (isset($labelsMap[$k]) && is_array($labelsMap[$k])) {
      $parts  = array_map('trim', explode(',', $raw));
      $labels = [];
      foreach ($parts as $p) {
        if ($p === '') continue;
        $labels[] = (string)($labelsMap[$k][$p] ?? $p);
      }
      $text = implode('、', $labels);
    } else {
      $text = $raw;
    }

    // 文字数制限
    if ($len !== null) {
      $text = function_exists('mb_substr') ? mb_substr($text, 0, $len, 'UTF-8') : substr($text, 0, $len);
    }

    // textarea は改行→<br>、それ以外は通常エスケープ
    if (($typeMap[$k] ?? '') === 'textarea') {
      return nl2br(h($text), false);
    }
    return h($text);
  }

  // 予約語
  if (array_key_exists($key, $map)) {
    if ($key === 'media') return $map[$key]; // media は生HTML
    $v = (string)$map[$key];
    if ($len !== null) {
      $v = function_exists('mb_substr') ? mb_substr($v, 0, $len, 'UTF-8') : substr($v, 0, $len);
    }
    return h($v);
  }

  return $m[0]; // 未知はそのまま
}, $tpl2);

  // 新規：{{LIST_FIELDS}} を置換（list_site=1 の項目から <dl> で出力・最大3件）
  if (strpos((string)$tpl, '{{LIST_FIELDS}}') !== false) {
    // list_site=1 の項目（ord順・最大3件）をカテゴリごとにキャッシュ（unit も取得）
    static $LF_CACHE = []; // ['slug' => [ ['key'=>..,'label'=>..,'type'=>..,'unit'=>..], ... ]]
    $lf = $LF_CACHE[$category] ?? null;
    if (!is_array($lf)) {
      try {
        $pdo = db();
        $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $stCid->execute([(string)$category]);
        $cid = (int)$stCid->fetchColumn();
        $lf = [];
        if ($cid > 0) {
          $st = $pdo->prepare('SELECT "key","label","type","unit" FROM fields WHERE category_id = ? AND list_site = 1 ORDER BY ord ASC, id ASC');
          $st->execute([$cid]);
          $lf = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        }
      } catch (Throwable $e) {
        $lf = [];
      }
      $LF_CACHE[$category] = $lf;
    }

    // 型マップ（textarea 判定用）
    $typeMap = macro__types_map((string)$category);

    $vals   = (array)($item['values'] ?? []);
    $pairs  = []; // [ ['label'=>'所在地', 'value'=>'江東区', 'type'=>'text'], ... ]
    foreach ($lf as $f) {
      $k    = (string)($f['key'] ?? '');
      $t    = (string)($f['type'] ?? 'text');
      $lbl  = (string)($f['label'] ?? $k);
      $unit = (string)($f['unit']  ?? '');
      if ($k === '') continue;
      $raw = (string)($vals[$k] ?? '');
      if ($raw === '') continue;

      // 値の整形（選択系→ラベル表示、checkboxは「、」連結／assetsは「メディアあり」）
      $disp = '';
      if ($t === 'checkbox') {
        $arr = array_values(array_filter(array_map('trim', explode(',', $raw)), 'strlen'));
        if ($arr) {
          $arr  = array_map(function($v) use ($k, $labelsMap){ return (string)($labelsMap[$k][$v] ?? $v); }, $arr);
          $disp = implode('、', $arr);
        }
      } elseif (in_array($t, ['select','radio'], true)) {
        $disp = (string)($labelsMap[$k][$raw] ?? $raw);
      } elseif ($t === 'currency') {
        $disp = macro__fmt_currency($raw);
      } elseif ($t === 'assets') {
        $disp = 'メディアあり';
      } else {
        $disp = $raw;
      }

      if ($disp !== '') {
        // 単位（assets 以外・unit が空でなければ、先頭スペース自動）
        if ($t !== 'assets' && $unit !== '') {
          $disp .= (preg_match('/^\s/u', $unit) ? '' : ' ') . $unit;
        }
        // 各値は控えめに（最大20文字）
        if (function_exists('mb_substr')) $disp = mb_substr($disp, 0, 20, 'UTF-8');
        else if (strlen($disp) > 20) $disp = substr($disp, 0, 20);
        $pairs[] = ['label' => $lbl, 'value' => $disp, 'type' => ($typeMap[$k] ?? $t)];
      }
    }

    // <dl> を生成（textarea は <br> 反映）
    $summary = '';
    if ($pairs) {
      $buf = '<dl class="tpcms-dl-table">';
      foreach ($pairs as $p) {
        $valHtml = ($p['type'] === 'textarea') ? nl2br(h((string)$p['value']), false) : h((string)$p['value']);
        $buf .= '<dt>'.h((string)$p['label']).'</dt><dd>'.$valHtml.'</dd>';
      }
      $buf .= '</dl>';
      $summary = $buf;
    }

    $out = str_replace('{{LIST_FIELDS}}', $summary, $out);
  }

  // テンプレ中の {{BASE_PATH}} も展開
  if (function_exists('macro_base_path')) {
    $out = str_replace('{{BASE_PATH}}', macro_base_path([], []), $out);
  }
  return $out;
}

/** 一覧：front_list() を呼び、カード風リストを出力。PAGER用に直近ペイジャも保存。 */
function macro_list(array $ctx, array $attrs): string {
  global $__macro_last_pager;

  $cat   = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  $query = is_array($ctx['query'] ?? null) ? $ctx['query'] : [];
  $res   = front_list($cat, $query);
  $__macro_last_pager = $res['pager'] ?? null;

  $items = $res['items'] ?? [];
  if (!$items) return '<p class="tpcms-empty">該当データがありません。</p>';

  // 外部1件テンプレの読み込み（あれば使用） ※ラッパー撤去
  $itemTpl = macro__load_list_item_tpl((string)$cat);
  if ($itemTpl !== '') {
    $buf = '';
    foreach ($items as $r) {
      $buf .= macro__render_list_item($r, $itemTpl, (string)$cat);
    }
    return $buf;
  }

  // フォールバック：カード型（list_visible=1 を要約行として自動表示）
  $buf = '<div class="tpcms-list cards">';

  // カテゴリの「一覧＝◯」項目（最大3件）を取得（ord順）
  $lvFields = [];
  try {
    $pdo = db();
    $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
    $stCid->execute([$cat]);
    $cid = (int)$stCid->fetchColumn();
    if ($cid > 0) {
      $stF = $pdo->prepare('SELECT "key","label","type" FROM fields WHERE category_id = ? AND list_visible = 1 ORDER BY ord ASC, id ASC LIMIT 3');
      $stF->execute([$cid]);
      $lvFields = $stF->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }
  } catch (Throwable $e) {
    $lvFields = [];
  }
  // value→label 変換用マップ（select/radio/checkbox）
  $labelsMap = macro__labels_map((string)$cat);

  foreach ($items as $r) {
    $id    = (int)($r['id'] ?? 0);
    $title = h((string)($r['title'] ?? ''));

    // メディア
    $asset = (string)($r['asset_url'] ?? '');
    $media = '';
    if ($asset !== '') {
      $ext = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
      if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
        $media = '<img src="'.h($asset).'" alt="" loading="lazy">';
      } elseif (in_array($ext, ['mp4','webm','mov'], true)) {
        $media = '<video src="'.h($asset).'" playsinline controls></video>';
      }
    }

    // 要約行の生成（list_visible=1 の値を「ラベル：値」を「｜」区切りで）
    $summary = '';
    if (!empty($lvFields)) {
      $vals = (array)($r['values'] ?? []);
      $parts = [];
      foreach ($lvFields as $lf) {
        $k = (string)($lf['key'] ?? '');
        if ($k === '') continue;
        $t = (string)($lf['type'] ?? 'text');
        $lbl = (string)($lf['label'] ?? $k);
        $raw = (string)($vals[$k] ?? '');
        if ($raw === '') continue;

        // 表示値を整形
        $disp = '';
        if ($t === 'checkbox') {
          $arr = array_values(array_filter(array_map('trim', explode(',', $raw)), 'strlen'));
          if ($arr) {
            $arr = array_map(function($v) use ($k, $labelsMap){ return (string)($labelsMap[$k][$v] ?? $v); }, $arr);
            $disp = implode('、', $arr);
          }
        } elseif (in_array($t, ['select','radio'], true)) {
          $disp = (string)($labelsMap[$k][$raw] ?? $raw);
        } elseif ($t === 'assets') {
          $disp = 'メディアあり';
        } else {
          $disp = $raw;
        }

        if ($disp !== '') {
          // 長さを控えめに（各値 最大20文字）
          if (function_exists('mb_substr')) $disp = mb_substr($disp, 0, 20, 'UTF-8');
          else { if (strlen($disp) > 20) $disp = substr($disp, 0, 20); }
          $parts[] = h($lbl).'：'.h($disp);
        }
      }
      if ($parts) $summary = implode('｜', $parts);
    }

    $detailUrl = url_for('/detail', ['category'=>(string)$cat, 'id'=>$id]);
    $buf .= '<article class="card">';
    if ($media !== '') $buf .= '<div class="card-media">'.$media.'</div>';
    $buf .= '<h3 class="card-title"><a href="'.h($detailUrl).'">'.$title.'</a></h3>';

    // 要約があれば優先表示。無ければ旧body要約（互換）
    if ($summary !== '') {
      $buf .= '<p class="card-meta">'.$summary.'</p>';
    } else {
      $desc = (string)($r['body'] ?? '');
      if ($desc !== '') {
        if (function_exists('mb_substr')) $desc = h(mb_substr($desc, 0, 120, 'UTF-8'));
        else $desc = h(substr($desc, 0, 120));
        $buf .= '<p class="card-desc">'.$desc.'</p>';
      }
    }

    $buf .= '</article>';
  }
  $buf .= '</div>';

  return $buf;
}

/** カテゴリ一覧（件数バッジ付き） */
function macro_categories(array $ctx, array $attrs): string {
  // show_zero="1" で件数0も表示（既定は非表示）
  $showZero = (string)($attrs['show_zero'] ?? '0') === '1';

  // 既存のサマリーを取得（互換）
  $cats = front_categories_summary(true);
  if (!$cats) return '';

  // ord マップをDBから取得（is_active=1 のみ）
  $ordMap = [];
  try {
    $st = db()->query('SELECT slug, COALESCE(ord, 999999) AS o FROM categories WHERE is_active = 1');
    foreach ($st->fetchAll(PDO::FETCH_ASSOC) ?: [] as $r) {
      $s = (string)($r['slug'] ?? '');
      if ($s !== '') $ordMap[$s] = (int)$r['o'];
    }
  } catch (Throwable $e) {
    // 失敗時は元の順序
  }

  // ord に従って並び替え（フォールバックは末尾）
  if (!empty($ordMap)) {
    usort($cats, function($a, $b) use ($ordMap) {
      $oa = $ordMap[(string)($a['slug'] ?? '')] ?? 999999;
      $ob = $ordMap[(string)($b['slug'] ?? '')] ?? 999999;
      if ($oa === $ob) return strcmp((string)$a['slug'], (string)$b['slug']);
      return $oa <=> $ob;
    });
  }

  // <li> 群のみ返す（外枠<ul>はテンプレ側）
  $buf = '';
  foreach ($cats as $c) {
    $slug = (string)$c['slug'];
    $name = (string)$c['name'];
    $cnt  = (int)$c['count'];
    if (!$showZero && $cnt <= 0) continue;

    $url = url_for('/list', ['category' => $slug]);
    $buf .= '<li><a href="'.h($url).'">'.h($name).'<span class="badge">【'.(string)$cnt.'】</span></a></li>';
  }
  return $buf;
}

// 現在のカテゴリの“表示名（name）”を返す（未指定時は空、取得失敗時はslug）
function macro_category_name(array $ctx, array $attrs): string {
  $slug = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  if ($slug === '') return '';
  try {
    $pdo = db();
    $st = $pdo->prepare('SELECT name FROM categories WHERE slug = ? LIMIT 1');
    $st->execute([$slug]);
    $name = (string)($st->fetchColumn() ?: '');
    return h($name !== '' ? $name : $slug);
  } catch (Throwable $e) {
    return h($slug);
  }
}

/** 新着：front_recents() を呼び、ラッパーパーツ（任意）＋アイテムパーツで出力 */
function macro_recents(array $ctx, array $attrs): string {
  $n = (int)($attrs['n'] ?? 8);
  if ($n <= 0) $n = 8;

  $res   = front_recents($n);
  $items = $res['items'] ?? [];
  if (!$items) return '<p class="tpcms-empty">新着はまだありません。</p>';

  // --- アイテムHTMLをまとめて生成（カテゴリ別テンプレ→汎用→フォールバック） ---
  static $TPL_CACHE = []; // ['slug' => 'tpl string' | '']
  $tplDefault = macro__load_list_item_tpl(null); // 汎用
  $itemsHtml = '';

  foreach ($items as $r) {
    $slug = (string)($r['category'] ?? '');
    if (!array_key_exists($slug, $TPL_CACHE)) {
      $t = macro__load_list_item_tpl($slug);
      $TPL_CACHE[$slug] = ($t !== '') ? $t : $tplDefault;
    }
    $tpl = (string)$TPL_CACHE[$slug];

    if ($tpl === '') {
      // フォールバック（LIST と同様の簡易カード）
      $id    = (int)($r['id'] ?? 0);
      $title = h((string)($r['title'] ?? ''));
      $desc  = (string)($r['body'] ?? '');
      if (function_exists('mb_substr')) $desc = h(mb_substr($desc, 0, 120, 'UTF-8'));
      else $desc = h(substr($desc, 0, 120));

      $asset = (string)($r['asset_url'] ?? '');
      $media = '';
      if ($asset !== '') {
        $ext = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
          $media = '<img src="'.h($asset).'" alt="" loading="lazy">';
        } elseif (in_array($ext, ['mp4','webm','mov'], true)) {
          $media = '<video src="'.h($asset).'" playsinline controls></video>';
        }
      }
      $detailUrl = url_for('/detail', ['category' => (string)$slug, 'id' => $id]);
      $itemsHtml .= '<article class="card">';
      if ($media !== '') $itemsHtml .= '<div class="card-media">'.$media.'</div>';
      $itemsHtml .= '<h3 class="card-title"><a href="'.h($detailUrl).'">'.$title.'</a></h3>';
      if ($desc !== '') $itemsHtml .= '<p class="card-desc">'.$desc.'</p>';
      $itemsHtml .= '</article>';
    } else {
      // 1件テンプレ（カテゴリ別 or 汎用）で描画
      $itemsHtml .= macro__render_list_item($r, $tpl, (string)$slug);
    }
  }

  // --- ラッパーパーツ（任意）を適用：/templates/_home.recents.html ---
  // 置換ルール：テンプレ内の {{items}} を、上で生成した $itemsHtml に差し込むだけ
  $wrap = macro__load_home_section_tpl('recents');
  if ($wrap !== '') {
    // {{BASE_PATH}} も展開可
    if (function_exists('macro_base_path')) {
      $wrap = str_replace('{{BASE_PATH}}', macro_base_path([], []), $wrap);
    }
    return str_replace('{{items}}', $itemsHtml, $wrap);
  }

  // ラッパーが無い場合の既定（従来とほぼ同一動作）
  return '<div class="tpcms-list recents">'.$itemsHtml.'</div>';
}

/** 特集：settings の FEATURE_* を参照し、PICKUP_TOPIC に一致する active=1 の新着を N件表示 */
function macro_feature_items(array $ctx, array $attrs): string {
  // 表示ON/OFFと各設定を取得（未設定時はフォールバック）
  $show  = (string)tpcms_setting('SHOW_FEATURE',  '0');
  if ($show !== '1') return '';
  $topic = (string)tpcms_setting('FEATURE_TOPIC', '');
  $title = (string)tpcms_setting('FEATURE_TITLE', '');
  $desc  = (string)tpcms_setting('FEATURE_DESC',  '');
  $limit = (int)tpcms_setting('FEATURE_LIMIT',    '8');
  if ($limit <= 0) $limit = 8;

  if ($topic === '') return '';

  // 対象アイテムの id と category を抽出（作成日の新しい順）
  $rows = [];
  try {
    $pdo = db();
    $sql = '
      SELECT i.id, i.category
        FROM items i
        JOIN item_values v ON v.item_id = i.id
                          AND v.field_key = "PICKUP_TOPIC"
                          AND v.value     = :topic
       WHERE i.active = 1
       ORDER BY i.created_at DESC, i.id DESC
       LIMIT :lim
    ';
    $st = $pdo->prepare($sql);
    $st->bindValue(':topic', $topic, PDO::PARAM_STR);
    $st->bindValue(':lim',   $limit, PDO::PARAM_INT);
    $st->execute();
    $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
  } catch (Throwable $e) {
    return ''; // 失敗時は静かに無表示
  }

  if (!$rows) return '';

  // 1件テンプレ（カテゴリ別→汎用）をキャッシュして描画
  static $TPL_CACHE = []; // ['slug' => 'tpl string' | '']
  $tplDefault = macro__load_list_item_tpl(null); // 汎用
  $itemsHtml  = '';

  foreach ($rows as $r) {
    $slug = (string)($r['category'] ?? '');
    $id   = (int)($r['id'] ?? 0);
    if ($slug === '' || $id <= 0) continue;

    // 詳細APIで1件取得しておく（values / asset_url / created_at 等が揃う）
    try {
      $rowFull = front_detail($slug, $id);
    } catch (Throwable $e) {
      $rowFull = null;
    }
    if (!is_array($rowFull)) continue;

    if (!array_key_exists($slug, $TPL_CACHE)) {
      $t = macro__load_list_item_tpl($slug);
      $TPL_CACHE[$slug] = ($t !== '') ? $t : $tplDefault;
    }
    $tpl = (string)$TPL_CACHE[$slug];

    if ($tpl === '') {
      // LISTのフォールバックに寄せる簡易カード（最低限）
      $titleDisp = h((string)($rowFull['title'] ?? ''));
      $asset     = (string)($rowFull['asset_url'] ?? '');
      $media     = '';
      if ($asset !== '') {
        $ext = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
          $media = '<img src="'.h($asset).'" alt="" loading="lazy">';
        } elseif (in_array($ext, ['mp4','webm','mov'], true)) {
          $media = '<video src="'.h($asset).'" playsinline controls></video>';
        }
      }
      $detailUrl = url_for('/detail', ['category'=>$slug, 'id'=>$id]);
      $itemsHtml .= '<article class="card">';
      if ($media !== '') $itemsHtml .= '<div class="card-media">'.$media.'</div>';
      $itemsHtml .= '<h3 class="card-title"><a href="'.h($detailUrl).'">'.$titleDisp.'</a></h3>';
      $itemsHtml .= '</article>';
    } else {
      $itemsHtml .= macro__render_list_item($rowFull, $tpl, $slug);
    }
  }

  if ($itemsHtml === '') return '';

  // ラッパーパーツの選択：tpl="side" などがあれば _home.feature.side.html を優先
  $wrap = '';
  $tplKey = (string)($attrs['tpl'] ?? $attrs['layout'] ?? '');
  $tplKey = preg_replace('~[^A-Za-z0-9_\-]+~', '', $tplKey);
  if ($tplKey !== '') {
      $wrap = macro__load_home_section_tpl('feature.' . $tplKey); // 例）_home.feature.side.html
  }
  if ($wrap === '') {
      $wrap = macro__load_home_section_tpl('feature');            // 既定：_home.feature.html
  }

  if ($wrap !== '') {
    if (function_exists('macro_base_path')) {
      $wrap = str_replace('{{BASE_PATH}}', macro_base_path([], []), $wrap);
    }
    $descHtml = nl2br(h($desc), false);
    return strtr($wrap, [
      '{{title}}' => h($title),
      '{{desc}}'  => $descHtml,
      '{{items}}' => $itemsHtml,
    ]);
  }

  // 既定ラッパー
  $h = '<section class="tpcms-feature">';
  if ($title !== '') $h .= '<h2>'.h($title).'</h2>';
  if ($desc  !== '') $h .= '<p class="tpcms-feature-desc">'.h($desc).'</p>';
  $h .= '<div class="tpcms-list feature">'.$itemsHtml.'</div>';
  $h .= '</section>';
  return $h;
}

// 内部：RELATED_QUERY の簡易パーサ
// 例）"ids: 1,2; cat: *; v.RELATED_TAGS: shop5, shop6; limit: 8"
function macro__parse_related_query(string $s, string $defaultCat): array {
  $out = [
    'cat'     => $defaultCat !== '' ? $defaultCat : '',
    'ids'     => [],
    'limit'   => 8,
    'filters' => [] // [ ['key'=>'RELATED_TAGS','tokens'=>['shop5','shop6']] , ... ]
  ];
  if ($s === '') return $out;

  // ; 区切りで分解
  foreach (preg_split('/;+/u', $s) as $seg) {
    $seg = trim($seg);
    if ($seg === '') continue;
    // key: val 形式
    if (!preg_match('/^\s*([A-Za-z0-9_.-]+)\s*:\s*(.+)\s*$/u', $seg, $m)) continue;
    $k = (string)$m[1];
    $v = (string)$m[2];

    if ($k === 'ids') {
      $ids = array_values(array_filter(array_map('trim', explode(',', $v)), 'strlen'));
      $ids = array_map('intval', $ids);
      $ids = array_values(array_filter($ids, fn($x)=> $x>0));
      if ($ids) $out['ids'] = $ids;

    } elseif ($k === 'cat') {
      $v = trim($v);
      if ($v === '*') $out['cat'] = '*';
      else {
        $slug = preg_replace('~[^A-Za-z0-9_\-]+~', '', $v);
        if ($slug !== '') $out['cat'] = $slug;
      }

    } elseif ($k === 'limit') {
      $n = max(1, (int)$v);
      if ($n > 100) $n = 100;
      $out['limit'] = $n;

    } elseif (str_starts_with($k, 'v.')) {
      $fieldKey = substr($k, 2);
      $fieldKey = preg_replace('~[^A-Za-z0-9_\-]+~', '', $fieldKey);
      if ($fieldKey === '') continue;
      $tokens = array_values(array_filter(array_map('trim', explode(',', $v)), 'strlen'));
      // トークン長は安全のため制限
      $tokens = array_map(function($t){
        $t = mb_substr($t, 0, 100, 'UTF-8');
        return $t;
      }, $tokens);
      if ($tokens) $out['filters'][] = ['key'=>$fieldKey, 'tokens'=>$tokens];
    }
  }
  return $out;
}

/** 関連データ：現在アイテムの RELATED_QUERY を解析し、該当アイテムを表示 */
function macro_related_items(array $ctx, array $attrs): string {
  // 現在のカテゴリ＆ID
  $cat = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  $id  = max(0, (int)($ctx['id'] ?? ($ctx['query']['id'] ?? 0)));
  if ($cat === '' || $id <= 0) return '';

  // 対象アイテムを取得（RELATED_QUERY を読む）
  try {
    $self = front_detail($cat, $id);
  } catch (\Throwable $e) {
    $self = null;
  }
  if (!is_array($self)) return '';

  $rq = (string)($self['values']['RELATED_QUERY'] ?? '');
  $conf = macro__parse_related_query($rq, (string)($self['category'] ?? $cat));
  $limit = (int)($conf['limit'] ?? 8);
  if ($limit <= 0) $limit = 8;

  // 何も条件が無ければ無表示
  $useIds   = !empty($conf['ids']);
  $useVkeys = !empty($conf['filters']);
  if (!$useIds && !$useVkeys) return '';

  // 検索SQLの組立て
  $rows = [];
  try {
    $pdo = db();

    if ($useIds) {
      // 明示ID指定
      $ids = array_slice($conf['ids'], 0, 200);
      $ph  = implode(',', array_fill(0, count($ids), '?'));
      $sql = 'SELECT id, category FROM items WHERE active=1 AND id IN ('.$ph.') AND id <> ?';
      $args = $ids;
      $args[] = $id; // 自分は除外
      if (($conf['cat'] ?? '') !== '*' && ($conf['cat'] ?? '') !== '') {
        $sql .= ' AND category = ?';
        $args[] = (string)$conf['cat'];
      }
      $sql .= ' ORDER BY created_at DESC, id DESC LIMIT '.(int)$limit;
      $st = $pdo->prepare($sql);
      $st->execute($args);
      $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];

    } else {
      // v.KEY の部分一致（OR）／複数KEYは AND
      $joins = [];
      $wheres = ['i.active = 1', 'i.id <> :self'];
      $bind = [':self' => $id];

      // カテゴリ絞り
      if (($conf['cat'] ?? '') !== '*' && ($conf['cat'] ?? '') !== '') {
        $wheres[] = 'i.category = :cat';
        $bind[':cat'] = (string)$conf['cat'];
      } else {
        // 省略時は「現在カテゴリのみ」
        if (!isset($conf['cat']) || $conf['cat'] === '') {
          $wheres[] = 'i.category = :cat';
          $bind[':cat'] = (string)$cat;
        }
      }

      $j = 0;
      foreach ($conf['filters'] as $f) {
        $j++;
        $alias = 'v'.$j;
        $key   = (string)($f['key'] ?? '');
        $tokens = (array)($f['tokens'] ?? []);
        if ($key === '' || !$tokens) continue;

        $joins[] = 'JOIN item_values '.$alias.' ON '.$alias.'.item_id = i.id AND '.$alias.'.field_key = :k'.$j;

        $ors = [];
        $bind[':k'.$j] = $key;

        $tidx = 0;
        foreach ($tokens as $t) {
          $tidx++;
          // LIKE 用に % と _ をエスケープ
          $t2 = str_replace(['\\','%','_'], ['\\\\','\%','\_'], $t);
          $param = ':t'.$j.'_'.$tidx;
          $ors[] = $alias.'.value LIKE '.$param.' ESCAPE \'\\\'';
          $bind[$param] = '%'.$t2.'%'; // 部分一致（contains）
        }
        if ($ors) {
          $wheres[] = '('.implode(' OR ', $ors).')';
        }
      }

      $sql = 'SELECT DISTINCT i.id, i.category FROM items i '
           . implode(' ', $joins)
           . ' WHERE ' . implode(' AND ', $wheres)
           . ' ORDER BY i.created_at DESC, i.id DESC'
           . ' LIMIT '.(int)$limit;

      $st = $pdo->prepare($sql);
      foreach ($bind as $k => $v) {
        $st->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
      }
      $st->execute();
      $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

  } catch (\Throwable $e) {
    return ''; // エラー時は静かに無表示
  }

  if (!$rows) return '';

  // 1件テンプレ（カテゴリ別→汎用）で描画
  static $TPL_CACHE = []; // ['slug' => 'tpl string' | '']
  $tplDefault = macro__load_list_item_tpl(null);
  $itemsHtml  = '';

  foreach ($rows as $r) {
    $slug = (string)($r['category'] ?? '');
    $iid  = (int)($r['id'] ?? 0);
    if ($slug === '' || $iid <= 0) continue;

    // 詳細APIで1件取得（asset_url / values / created_at などを揃える）
    try {
      $rowFull = front_detail($slug, $iid);
    } catch (\Throwable $e) {
      $rowFull = null;
    }
    if (!is_array($rowFull)) continue;

    if (!array_key_exists($slug, $TPL_CACHE)) {
      $t = macro__load_list_item_tpl($slug);
      $TPL_CACHE[$slug] = ($t !== '') ? $t : $tplDefault;
    }
    $tpl = (string)$TPL_CACHE[$slug];

    if ($tpl === '') {
      // 簡易カード（フォールバック）
      $titleDisp = h((string)($rowFull['title'] ?? ''));
      $asset     = (string)($rowFull['asset_url'] ?? '');
      $media     = '';
      if ($asset !== '') {
        $ext = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
          $media = '<img src="'.h($asset).'" alt="" loading="lazy">';
        } elseif (in_array($ext, ['mp4','webm','mov'], true)) {
          $media = '<video src="'.h($asset).'" playsinline controls></video>';
        }
      }
      $detailUrl = url_for('/detail', ['category'=>$slug, 'id'=>$iid]);
      $itemsHtml .= '<article class="card">';
      if ($media !== '') $itemsHtml .= '<div class="card-media">'.$media.'</div>';
      $itemsHtml .= '<h3 class="card-title"><a href="'.h($detailUrl).'">'.$titleDisp.'</a></h3>';
      $itemsHtml .= '</article>';
    } else {
      $itemsHtml .= macro__render_list_item($rowFull, $tpl, $slug);
    }
  }

  if ($itemsHtml === '') return '';

  // ラッパー：/templates/_detail.related.html があれば優先
  $wrap = macro__load_detail_section_tpl('related');
  if ($wrap !== '') {
    if (function_exists('macro_base_path')) {
      $wrap = str_replace('{{BASE_PATH}}', macro_base_path([], []), $wrap);
    }
    return strtr($wrap, ['{{items}}' => $itemsHtml]);
  }

  // 既定ラッパー
  return '<section class="tpcms-related"><h2>関連データ</h2><div class="tpcms-list related">'.$itemsHtml.'</div></section>';
}

/** ページャ：直前の LIST の結果を使って番号リンクを生成 */
function macro_pager(array $ctx, array $attrs): string {
    global $__macro_last_pager;
    $p = $__macro_last_pager;
    if (!$p) return '';
    $page = (int)$p['page']; $total = (int)$p['total_pages'];
    if ($total <= 1) return '';
    $qsBase = $ctx['query'] ?? [];
    unset($qsBase['page']);
    $base = url_for('/list', $qsBase);

    // ウィンドウ（5ページ）
    $window = 5;
    $half   = intdiv($window-1, 2);
    $start  = max(1, $page - $half);
    $end    = min($total, $start + $window - 1);
    $start  = max(1, $end - $window + 1);

    $h = '<div class="pager">';
    $h .= ($page>1)
        ? '<a href="'.$base.'&page='.($page-1).'">&lt;&lt; 前へ</a>'
        : '<span>&lt;&lt; 前へ</span>';
    for ($i=$start; $i<=$end; $i++) {
        if ($i === $page) $h .= '<span>'.$i.'</span>';
        else $h .= '<a href="'.$base.'&page='.$i.'">'.$i.'</a>';
    }
    $h .= ($page<$total)
        ? '<a href="'.$base.'&page='.($page+1).'">次へ &gt;&gt;</a>'
        : '<span>次へ &gt;&gt;</span>';
    $h .= '</div>';
    return $h;
}

/** 内部：カテゴリslug→ { field_key: { value: label } } のマップを返す（select/radio/checkbox対象） */
function macro__labels_map(?string $slug): array {
  static $CACHE = []; // ['slug' => ['KEY' => ['v'=>'L', ...], ...]]
  if (!is_string($slug) || $slug === '') return [];
  if (isset($CACHE[$slug])) return $CACHE[$slug];

  try {
    $pdo = db();
    $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
    $stCid->execute([$slug]);
    $cid = (int)$stCid->fetchColumn();

    $map = [];
    if ($cid > 0) {
      $st = $pdo->prepare('SELECT "key", type, options FROM fields WHERE category_id = ? AND type IN ("select","radio","checkbox")');
      $st->execute([$cid]);
      foreach ($st->fetchAll(PDO::FETCH_ASSOC) ?: [] as $r) {
        $k = (string)($r['key'] ?? '');
        if ($k === '') continue;
        $optsJ = (string)($r['options'] ?? '');
        $arr = json_decode($optsJ, true);
        if (!is_array($arr)) continue;
        $mm = [];
        foreach ($arr as $o) {
          $v = isset($o['value']) ? (string)$o['value'] : '';
          $l = isset($o['label']) ? (string)$o['label'] : $v;
          if ($v !== '') $mm[$v] = $l;
        }
        if ($mm) $map[$k] = $mm;
      }
    }
    return $CACHE[$slug] = $map;
  } catch (Throwable $e) {
    return $CACHE[$slug] = [];
  }
}

// 内部：カテゴリの「key => type」マップ（textarea判定に使用）
function macro__types_map(?string $slug): array {
  static $CACHE = []; // ['slug' => ['KEY' => 'type', ...]]
  if (!is_string($slug) || $slug === '') return [];
  if (isset($CACHE[$slug])) return $CACHE[$slug];

  try {
    $pdo = db();
    $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
    $stCid->execute([$slug]);
    $cid = (int)$stCid->fetchColumn();

    $map = [];
    if ($cid > 0) {
      $st = $pdo->prepare('SELECT "key", type FROM fields WHERE category_id = ?');
      $st->execute([$cid]);
      foreach ($st->fetchAll(PDO::FETCH_ASSOC) ?: [] as $r) {
        $k = (string)($r['key'] ?? '');
        $t = (string)($r['type'] ?? '');
        if ($k !== '') $map[$k] = $t;
      }
    }
    return $CACHE[$slug] = $map;
  } catch (Throwable $e) {
    return $CACHE[$slug] = [];
  }
}

// -------- currency の桁区切り表示（- / 小数 / 全角→半角に対応） --------
if (!function_exists('macro__fmt_currency')) {
  function macro__fmt_currency(string $s): string {
    $s = trim($s);
    if ($s === '') return $s;
    if (function_exists('mb_convert_kana')) {
      $s = mb_convert_kana($s, 'n', 'UTF-8'); // 全角数字→半角
    }
    if (!preg_match('/^-?\d+(?:\.\d+)?$/', $s)) return $s; // 数字以外はそのまま
    $neg = ($s[0] === '-') ? '-' : '';
    if ($neg !== '') $s = substr($s, 1);
    $parts = explode('.', $s, 2);
    $int = $parts[0];
    $frac = $parts[1] ?? '';
    // 整数部にカンマ
    $int = preg_replace('/\B(?=(\d{3})+(?!\d))/', ',', $int);
    return $neg . $int . ($frac !== '' ? ('.' . $frac) : '');
  }
}

// -------- NEW/UP 設定の取得（settings がまだ無ければフォールバック） --------
if (!function_exists('tpcms_setting')) {
    function tpcms_setting(string $key, string $default): string {
        try {
            $pdo = db();
            // settingsテーブルが無い／未作成でも例外を握りつぶして既定値を返す
            $st = $pdo->prepare('SELECT value FROM settings WHERE key = ? LIMIT 1');
            $st->execute([$key]);
            $v = $st->fetchColumn();
            if ($v === false || $v === null || $v === '') return $default;
            return (string)$v;
        } catch (Throwable $e) {
            return $default;
        }
    }
}

// -------- NEW/UP バッジのHTMLを返す（空なら表示しない） --------
if (!function_exists('macro__badge_html')) {
    /**
     * @param string $type 'new' | 'up'
     * @param int    $days 判定日数（0以下なら常に非表示）
     * @param string $createdAt ISO文字列（items.created_at）
     * @param string $updatedAt ISO文字列（items.updated_at）
     */
    function macro__badge_html(string $type, int $days, string $createdAt, string $updatedAt): string {
        if ($days <= 0) return '';
        $now = time();

        $tC = ($createdAt !== '' ? strtotime($createdAt) : false);
        $tU = ($updatedAt !== '' ? strtotime($updatedAt) : false);

        if ($type === 'new') {
            if ($tC === false) return '';
            // NEW期間内のみ表示
            if (($now - $tC) <= ($days * 86400)) {
                return '<span class="new-icon">NEW</span>';
            }
            return '';
        } else { // 'up'
            if ($tU === false) return '';
            // NEW優先：NEW_DAYS 期間内は UP を抑制（サイト設定の NEW_DAYS を参照）
            $newDays = (int)tpcms_setting('NEW_DAYS', '7');
            if ($tC !== false && ($now - $tC) <= ($newDays * 86400)) {
                return '';
            }
            // 「作成と同時更新」は除外（updated_at > created_at のときだけ）
            if ($tC !== false && $tU <= $tC) return '';
            // UP期間内のみ表示
            if (($now - $tU) <= ($days * 86400)) {
                return '<span class="up-icon">UP</span>';
            }
            return '';
        }
    }
}

/** 内部：/templates/_detail.item.html を読み込み（無ければ空文字） */
function macro__load_detail_tpl(?string $slug = null): string {
  $base = rtrim(tpcms_templates_dir(), '/') . '/';
  $candidates = [];
  if (is_string($slug) && $slug !== '') {
    $safe = preg_replace('~[^A-Za-z0-9_\-]+~', '', $slug);
    if ($safe !== '') {
      $candidates[] = $base . '_detail.item.' . $safe . '.html'; // 例）_detail.item.baibai.html
    }
  }
  $candidates[] = $base . '_detail.item.html'; // 汎用フォールバック

  foreach ($candidates as $path) {
    if (is_file($path)) {
      $s = (string)@file_get_contents($path);
      return is_string($s) ? $s : '';
    }
  }
  return '';
}

/** 内部：詳細テンプレを埋め込み（{{id}}, {{title}}, {{body}}, {{body:120}}, {{asset_url}}, {{media}}, {{created_at}}, {{updated_at}}, {{values.KEY}}） */
function macro__render_detail(array $row, string $tpl): string {
  // まず items.asset_file（従来）を試す
  $asset = (string)($row['asset_url'] ?? '');

  // 空なら、カテゴリの assets フィールドの「並び順（ord ASC, id ASC）」に従って最初の値を使う
  // ついでに「全メディア一覧」を収集してギャラリー用に使う
  $filesOrdered = []; // 並び順に従ったファイル名配列（重複なし）
  if ($asset === '') {
    $vals = (array)($row['values'] ?? []);
    $category = (string)($row['category'] ?? '');

    // カテゴリごとの assetsキー配列をキャッシュ
    static $ASSET_KEYS_CACHE = []; // ['slug' => ['key1','key2',...]]
    $keys = $ASSET_KEYS_CACHE[$category] ?? null;

    if (!is_array($keys)) {
      try {
        $pdo = db();
        // slug → category_id
        $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $stCid->execute([$category]);
        $cid = (int)$stCid->fetchColumn();

        $keys = [];
        if ($cid > 0) {
          $stK = $pdo->prepare('SELECT "key" FROM fields WHERE category_id = ? AND type = "assets" ORDER BY ord ASC, id ASC');
          $stK->execute([$cid]);
          foreach ($stK->fetchAll(PDO::FETCH_COLUMN) ?: [] as $k) {
            $k = (string)$k;
            if ($k !== '') $keys[] = $k;
          }
        }
      } catch (Throwable $e) {
        $keys = [];
      }
      $ASSET_KEYS_CACHE[$category] = $keys;
    }

    // 並び順1番目から順に、値が入っているファイルを収集
    $seen = [];
    if (!empty($keys)) {
      foreach ($keys as $akey) {
        $fn = (string)($vals[$akey] ?? '');
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          if (!isset($seen[$fn])) {
            $filesOrdered[] = $fn;
            $seen[$fn] = true;
          }
        }
      }
    }

    // それでも空なら、values の中から“それっぽい拡張子”の値をフォールバック収集
    if (empty($filesOrdered) && !empty($vals)) {
      foreach ($vals as $v) {
        $fn = (string)$v;
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          if (!isset($seen[$fn])) {
            $filesOrdered[] = $fn;
            $seen[$fn] = true;
          }
        }
      }
    }

    // 先頭をメイン asset に反映
    if (!empty($filesOrdered)) {
      $asset = asset_url('/uploads/' . $filesOrdered[0], false);
    }
  } else {
    // items.asset_file がある場合でも、ギャラリー用に item_values をざっと拾う
    $vals = (array)($row['values'] ?? []);
    $seen = [];
    foreach ($vals as $v) {
      $fn = (string)$v;
      if ($fn === '') continue;
      $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
      if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
        if (!isset($seen[$fn])) {
          $filesOrdered[] = $fn;
          $seen[$fn] = true;
        }
      }
    }
  }

  // <img> / <video> を自動生成（メイン）
  $media = '';
  if ($asset !== '') {
    $ext2 = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
    if (in_array($ext2, ['jpg','jpeg','png','gif','webp'], true)) {
      $media = '<img src="'.h($asset).'" alt="">';
    } elseif (in_array($ext2, ['mp4','webm','mov'], true)) {
      $media = '<video src="'.h($asset).'" playsinline controls></video>';
    }
  }

  // ギャラリーHTML（全メディア。ラッパー要素なし）
  $galleryHtml = '';
  if (!empty($filesOrdered)) {
    foreach ($filesOrdered as $fn) {
      $url = asset_url('/uploads/' . $fn, false);
      $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
      if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
        $galleryHtml .= '<img src="'.h($url).'" alt="">';
      } elseif (in_array($ext, ['mp4','webm','mov'], true)) {
        $galleryHtml .= '<video src="'.h($url).'" playsinline controls></video>';
      }
    }
  }

  $map = [
    'id'         => (string)($row['id'] ?? ''),
    'category'   => (string)($row['category'] ?? ''),
    'title'      => (string)($row['title'] ?? ''),
    'body'       => (string)($row['body'] ?? ''),
    'asset_url'  => $asset,           // 並び順に基づくメインURL
    'created_at' => (string)($row['created_at'] ?? ''),
    'updated_at' => (string)($row['updated_at'] ?? ''),
    'media'      => $media,           // 生HTML（メイン1件）
    'gallery'    => $galleryHtml,     // 生HTML（全件）
  ];
  $vals = (array)($row['values'] ?? []);

  // 簡易IF（TPCMS:IF）を先に評価
  $tpl2 = macro__tpl_if((string)$tpl, $map, $vals);

  // プレースホルダー展開
  // カテゴリに応じた「value→label」変換マップ
  $labelsMap = macro__labels_map((string)($row['category'] ?? ''));
  $typeMap   = macro__types_map((string)($row['category'] ?? ''));

  // プレースホルダー展開（values.KEY は可能なら label を表示）
  $out = preg_replace_callback('/\{\{\s*([A-Za-z0-9_.]+)(?::(\d+))?\s*\}\}/u', function($m) use ($map, $vals, $labelsMap, $typeMap) {
    $key = (string)$m[1];
    $len = isset($m[2]) ? max(0, (int)$m[2]) : null;

    // NEW/UP バッジ
    if ($key === 'NEW_BADGE' || $key === 'UP_BADGE') {
      $days = ($len !== null)
        ? $len
        : (int)( $key === 'NEW_BADGE'
              ? tpcms_setting('NEW_DAYS', '7')
              : tpcms_setting('UP_DAYS',  '7') );
      $createdAt = (string)($map['created_at'] ?? '');
      $updatedAt = (string)($map['updated_at'] ?? '');
      $type = ($key === 'NEW_BADGE') ? 'new' : 'up';
      return macro__badge_html($type, max(0, (int)$days), $createdAt, $updatedAt);
    }

    // values.KEY（select/radio/checkboxはラベル化、textareaは <br> 反映）
    if (strpos($key, 'values.') === 0) {
      $k = substr($key, 7);
      $raw = (string)($vals[$k] ?? '');
      if ($raw === '') return '';

      if (isset($labelsMap[$k]) && is_array($labelsMap[$k])) {
        // checkbox など複数値対応
        $parts = array_map('trim', explode(',', $raw));
        $labels = [];
        foreach ($parts as $p) {
          if ($p === '') continue;
          $labels[] = (string)($labelsMap[$k][$p] ?? $p);
        }
        $text = implode('、', $labels);
      } else {
        $text = $raw;
      }

      if ($len !== null) {
        $text = function_exists('mb_substr') ? mb_substr($text, 0, $len, 'UTF-8') : substr($text, 0, $len);
      }

      if (($typeMap[$k] ?? '') === 'textarea') {
        return nl2br(h($text), false);
      }
      return h($text);
    }

    // 予約語（media / gallery は生HTML、body は <br> 反映、それ以外はエスケープ）
    if (array_key_exists($key, $map)) {
      if ($key === 'media' || $key === 'gallery') return $map[$key];
      $v = (string)$map[$key];
      if ($key === 'body') {
        if ($len !== null) {
          $v = function_exists('mb_substr') ? mb_substr($v, 0, $len, 'UTF-8') : substr($v, 0, $len);
        }
        return nl2br(h($v), false);
      }
      if ($len !== null) {
        $v = function_exists('mb_substr') ? mb_substr($v, 0, $len, 'UTF-8') : substr($v, 0, $len);
      }
      return h($v);
    }

    return $m[0];
  }, $tpl2);

  // 新規：{{DETAIL_FIELDS}} を置換（detail_site=1 の項目を <dl> で出力）
  if (strpos((string)$tpl, '{{DETAIL_FIELDS}}') !== false) {
    static $DF_CACHE = []; // ['slug' => [ ['key'=>..,'label'=>..,'type'=>..,'unit'=>..], ... ]]
    $slug = (string)($row['category'] ?? '');
    $df = $DF_CACHE[$slug] ?? null;
    if (!is_array($df)) {
      try {
        $pdo = db();
        $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $stCid->execute([$slug]);
        $cid = (int)$stCid->fetchColumn();
        $df = [];
        if ($cid > 0) {
          $st = $pdo->prepare('SELECT "key","label","type","unit" FROM fields WHERE category_id = ? AND detail_site = 1 ORDER BY ord ASC, id ASC');
          $st->execute([$cid]);
          $df = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        }
      } catch (Throwable $e) {
        $df = [];
      }
      $DF_CACHE[$slug] = $df;
    }

    // 型マップ（textarea 判定用）
    $typeMap = macro__types_map($slug);

    $vals = (array)($row['values'] ?? []);
    $items = [];

    foreach ($df as $f) {
      $k    = (string)($f['key'] ?? '');
      $t    = (string)($f['type'] ?? 'text');
      $lbl  = (string)($f['label'] ?? $k);
      $unit = (string)($f['unit']  ?? '');
      if ($k === '') continue;
      $raw = (string)($vals[$k] ?? '');
      if ($raw === '') continue;

      $disp = '';
      if ($t === 'checkbox') {
        $arr = array_values(array_filter(array_map('trim', explode(',', $raw)), 'strlen'));
        if ($arr) {
          $arr  = array_map(function($v) use ($k, $labelsMap){ return (string)($labelsMap[$k][$v] ?? $v); }, $arr);
          $disp = implode('、', $arr);
        }
      } elseif (in_array($t, ['select','radio'], true)) {
        $disp = (string)($labelsMap[$k][$raw] ?? $raw);
      } elseif ($t === 'currency') {
        $disp = macro__fmt_currency($raw);
      } elseif ($t === 'assets') {
        $disp = 'メディアあり';
      } else {
        $disp = $raw;
      }

      if ($disp !== '') {
        // 単位（assets 以外・unit が空でなければ、先頭スペース自動）
        if ($t !== 'assets' && $unit !== '') {
          $disp .= (preg_match('/^\s/u', $unit) ? '' : ' ') . $unit;
        }
        $items[] = ['label' => $lbl, 'value' => $disp, 'type' => ($typeMap[$k] ?? $t)];
      }
    }

    // <dl> を生成（textarea は <br> 反映）
    $detailHtml = '';
    if ($items) {
      $buf = '<dl class="tpcms-dl-table">';
      foreach ($items as $p) {
        $valHtml = ($p['type'] === 'textarea') ? nl2br(h((string)$p['value']), false) : h((string)$p['value']);
        $buf .= '<dt>'.h((string)$p['label']).'</dt><dd>'.$valHtml.'</dd>';
      }
      $buf .= '</dl>';
      $detailHtml = $buf;
    }

    $out = str_replace('{{DETAIL_FIELDS}}', $detailHtml, $out);
  }

  // テンプレ中の {{BASE_PATH}} も展開
  if (function_exists('macro_base_path')) {
    $out = str_replace('{{BASE_PATH}}', macro_base_path([], []), $out);
  }
  return $out;
}

/** 詳細：front_detail() を呼び、メインアセット＋本文＋値テーブルを出力 */
function macro_detail(array $ctx, array $attrs): string {
  $cat = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  $id  = max(0, (int)($ctx['id'] ?? ($ctx['query']['id'] ?? 0)));
  $row = front_detail($cat, $id);
  if (!$row) return '<p class="tpcms-empty">該当データが見つかりません。</p>';

  // 外部 詳細テンプレがあればそれを使う
  $tpl = macro__load_detail_tpl((string)$cat);
  if ($tpl !== '') {
    return macro__render_detail($row, $tpl);
  }

  // フォールバック（従来の最小出力）※並び順1番目の assets を優先
  $media = '';
  $asset = (string)($row['asset_url'] ?? '');

  if ($asset === '') {
    $vals = (array)($row['values'] ?? []);
    $category = (string)($row['category'] ?? '');

    static $ASSET_KEYS_CACHE2 = [];
    $keys = $ASSET_KEYS_CACHE2[$category] ?? null;

    if (!is_array($keys)) {
      try {
        $pdo = db();
        $stCid = $pdo->prepare('SELECT id FROM categories WHERE slug = ? LIMIT 1');
        $stCid->execute([$category]);
        $cid = (int)$stCid->fetchColumn();

        $keys = [];
        if ($cid > 0) {
          $stK = $pdo->prepare('SELECT "key" FROM fields WHERE category_id = ? AND type = "assets" ORDER BY ord ASC, id ASC');
          $stK->execute([$cid]);
          foreach ($stK->fetchAll(PDO::FETCH_COLUMN) ?: [] as $k) {
            $k = (string)$k;
            if ($k !== '') $keys[] = $k;
          }
        }
      } catch (Throwable $e) {
        $keys = [];
      }
      $ASSET_KEYS_CACHE2[$category] = $keys;
    }

    if (!empty($keys)) {
      foreach ($keys as $akey) {
        $fn = (string)($vals[$akey] ?? '');
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          $asset = asset_url('/uploads/' . $fn, false);
          break;
        }
      }
    }

    if ($asset === '' && !empty($vals)) {
      foreach ($vals as $v) {
        $fn = (string)$v;
        if ($fn === '') continue;
        $ext = strtolower(pathinfo($fn, PATHINFO_EXTENSION));
        if (in_array($ext, ['jpg','jpeg','png','gif','webp','mp4','webm','mov'], true)) {
          $asset = asset_url('/uploads/' . $fn, false);
          break;
        }
      }
    }
  }

  if ($asset !== '') {
    $ext2 = strtolower(pathinfo(parse_url($asset, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
    if (in_array($ext2, ['jpg','jpeg','png','gif','webp'], true)) {
      $media = '<img src="'.h($asset).'" alt="">';
    } elseif (in_array($ext2, ['mp4','webm','mov'], true)) {
      $media = '<video src="'.h($asset).'" playsinline controls></video>';
    }
  }

  $title = h((string)($row['title'] ?? ''));
  $body  = nl2br(h((string)($row['body'] ?? '')), false);

  $vals = (array)($row['values'] ?? []);
  $tb = '';
  if ($vals) {
    $tb .= '<table class="tpcms-detail"><tbody>';
    foreach ($vals as $k => $v) {
      if ($k === 'map_iframe') continue; // MAP は {{MAP}} で出す
      $tb .= '<tr><th>'.h((string)$k).'</th><td>'.h((string)$v).'</td></tr>';
    }
    $tb .= '</tbody></table>';
  }

  return '<article class="tpcms-detail">'.
         ($media!=='' ? '<div class="detail-media">'.$media.'</div>' : '').
         '<h1>'.$title.'</h1>'.
         ($body!=='' ? '<div class="detail-body">'.$body.'</div>' : '').
         $tb.
         '</article>';
}

/** MAP：values['map_iframe'] を許可ドメインのみ通して差し込み（それ以外は無視） */
function macro_map(array $ctx, array $attrs): string {
    $cat = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
    $id  = max(0, (int)($ctx['id'] ?? ($ctx['query']['id'] ?? 0)));
    $row = front_detail($cat, $id);
    if (!$row) return '';

    $html = (string)($row['values']['map_iframe'] ?? '');
    if ($html === '') return '';

    // ---- 許可ルール（必要に応じて追加可）----
    // host は末尾一致、path は先頭一致でチェック
    $ALLOWED = [
        ['host' => 'google.com',   'suffix' => '.google.com',   'path' => '/maps'],
        ['host' => 'google.co.jp', 'suffix' => '.google.co.jp', 'path' => '/maps'],
    ];

    $isAllowedUrl = function (string $url) use ($ALLOWED): bool {
        $p = @parse_url($url);
        if (!is_array($p) || empty($p['host'])) return false;
        $host = strtolower((string)$p['host']);
        $path = (string)($p['path'] ?? '');

        foreach ($ALLOWED as $r) {
            $okHost = ($host === $r['host']) || str_ends_with($host, $r['suffix']);
            $okPath = str_starts_with($path, $r['path']);
            if ($okHost && $okPath) return true;
        }
        return false;
    };

    // iframe の src 抽出（" または ' の両方に対応）
    $src = '';
    if (preg_match('/<iframe\b[^>]*\bsrc\s*=\s*"([^"]+)"/i', $html, $m)) {
        $src = (string)$m[1];
    } elseif (preg_match("/<iframe\b[^>]*\bsrc\s*=\s*'([^']+)'/i", $html, $m)) {
        $src = (string)$m[1];
    }

    // 許可外は出力しない（安全のため空文字）
    if ($src === '' || !$isAllowedUrl($src)) {
        return '';
    }

    // 許可OK：そのまま返す（テンプレ側で枠を用意）
    return $html;
}

/** 問い合わせフォーム（詳細ページ用）：/contact/submit.php へ POST
 *  - 外部テンプレ /templates/_contact.form.html があればそれを優先
 *  - プレースホルダー：
 *      {{action}} {{csrf}} {{category}} {{id}} {{title}}
 *  - 既定フォームにはハニーポット(_hp)を含む
 */
function macro_contact_form(array $ctx, array $attrs): string {
  $cat = preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)($ctx['category'] ?? ''));
  $id  = max(0, (int)($ctx['id'] ?? ($ctx['query']['id'] ?? 0)));

  // 表示直後送信の抑止用：表示時刻を記録（キー=category#id）
  $__k = $cat . '#' . ($id > 0 ? (string)$id : '-');
  if (!isset($_SESSION['tpcms_contact_shown'])) $_SESSION['tpcms_contact_shown'] = [];
  $_SESSION['tpcms_contact_shown'][$__k] = time();

  // エラー復元（submit.phpでセットされた値）
  $__err = (string)($_SESSION['tpcms_contact_err'][$__k]['code'] ?? '');
  $__val = (array)($_SESSION['tpcms_contact_err'][$__k]['values'] ?? ['name'=>'','email'=>'','message'=>'','extra'=>[]]);
  $__errMsg = '';
  switch ($__err) {
    case 'timegate':   $__errMsg = '表示直後の送信はできません。数秒待ってから再度お試しください。'; break;
    case 'rate_limit': $__errMsg = '短時間に送信が続いたため一時的に制限されています（IPv4:/24で1時間30回、IPv6:/64で1時間60回まで）。'; break;
    case 'invalid':    $__errMsg = '入力内容に不備があります。お名前・メール・本文をご確認ください。'; break;
    case 'mail_config':$__errMsg = '送信先メールアドレスが未設定のため送信できませんでした。'; break;
  }
  $__alert = ($__errMsg !== '') ? '<p id="contact-result" class="err">'.$__errMsg.'</p>' : '';

  // 成功時（detail?sent=1）のメッセージのみを表示してフォームは出さない
  if ((string)($ctx['query']['sent'] ?? '') === '1') {
    return '<p id="contact-result" class="ok">送信完了ありがとうございました。</p>';
  }

  // タイトル取得（存在しなくても動作は継続）
  $row   = ($cat !== '' && $id > 0) ? front_detail($cat, $id) : null;
  $title = is_array($row) ? (string)($row['title'] ?? '') : '';

  $action = url_for('/contact/submit.php');
  $csrf   = csrf_input_tag();

  // 外部テンプレ優先：/templates/_contact.form.html
  $tpl = macro__load_contact_tpl();
  if ($tpl !== '') {
    $repl = [
      '{{action}}'   => h($action),
      '{{csrf}}'     => $csrf, // そのまま生挿入
      '{{category}}' => h($cat),
      '{{id}}'       => (string)$id,
      '{{title}}'    => h($title),
      '{{name}}'     => h((string)($__val['name'] ?? '')),
      '{{email}}'    => h((string)($__val['email'] ?? '')),
      '{{message}}'  => h((string)($__val['message'] ?? '')),
      '{{alert}}'    => $__alert,
    ];
    $out = strtr($tpl, $repl);
    // {{extra.KEY}} → エラー復元値を展開（配列は「、」結合）
    if (is_array($__val['extra'] ?? null)) {
      $out = preg_replace_callback('/\{\{\s*extra\.([A-Za-z0-9_\-\/]+)\s*\}\}/u', function($m) use ($__val) {
        $k = (string)$m[1];
        $v = $__val['extra'][$k] ?? '';
        if (is_array($v)) $v = implode('、', array_map('strval', $v));
        return h((string)$v);
      }, $out);
    }
    // テンプレに {{alert}} が無ければ、先頭に差し込む
    if (strpos($out, '{{alert}}') === false && $__alert !== '') {
      $out = $__alert . $out;
    }
    // 一度表示したエラーはクリア
    unset($_SESSION['tpcms_contact_err'][$__k]);
    return $out;
  }

  // フォールバック（最小フォーム）
  $ctxInfo = '';
  if ($cat !== '' && $id > 0) {
    $ctxInfo = '<p class="tpcms-contact-target">お問い合わせ対象：'
             . h($title) . '（#' . (string)$id . '）</p>';
  }

  // ハニーポット
  $hp = '<div style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;">'
      . '<label>Leave this field empty<input type="text" name="_hp" tabindex="-1" autocomplete="off"></label>'
      . '</div>';

  // hidden：対象ID/タイトル
  $hidden = '<input type="hidden" name="item_category" value="'.h($cat).'">'
          . '<input type="hidden" name="item_id" value="'.(string)$id.'">'
          . '<input type="hidden" name="item_title" value="'.h($title).'">';

  $nameVal = h((string)($__val['name'] ?? ''));
  $mailVal = h((string)($__val['email'] ?? ''));
  $msgVal  = h((string)($__val['message'] ?? ''));

  $alertHtml = $__alert; // 上で生成したエラー

  unset($_SESSION['tpcms_contact_err'][$__k]); // クリア

  return <<<HTML
{$alertHtml}
<form id="contact-form" class="tpcms-contact" method="post" action="{$action}">
  {$csrf}
  {$hp}
  {$hidden}
  {$ctxInfo}
  <p>
    <label>お名前
      <input type="text" name="name" class="input" required value="{$nameVal}">
    </label>
  </p>
  <p>
    <label>メールアドレス
      <input type="email" name="email" class="input" required autocomplete="email" value="{$mailVal}">
    </label>
  </p>
  <p>
    <label>お問い合わせ内容
      <textarea name="message" class="input" rows="6" required>{$msgVal}</textarea>
    </label>
  </p>
  <p><button type="submit" class="btn1">送信する</button></p>
</form>
HTML;
}

/**
 * マクロ置換本体
 * @param string $html  テンプレHTML（{{...}} を含む）
 * @param array  $ctx   ['category'=>slug, 'id'=>int, 'query'=>$_GET] など
 */
function macro_render(string $html, array $ctx = []): string {
    // --- 外枠テンプレ用の簡易IF（MAP/values.* のみ判定） ---
    // detail.html のように category+id があれば、該当レコードを取得して判定に使う
    $ifMap  = [];
    $ifVals = [];
    try {
        $cat = isset($ctx['category']) ? preg_replace('~[^A-Za-z0-9_\-]+~', '', (string)$ctx['category']) : '';
        $id  = isset($ctx['id']) ? (int)$ctx['id'] : (int)($ctx['query']['id'] ?? 0);
        if ($cat !== '' && $id > 0) {
            $row = front_detail($cat, $id);
            if (is_array($row)) {
                // values.* はそのまま IF 判定に使えるよう渡す
                $ifVals = (array)($row['values'] ?? []);
                // MAP（map_iframe）の有無で判定
                $ifMap['map'] = (string)($row['values']['map_iframe'] ?? '');
            }
        }
    } catch (\Throwable $e) {
        // 何もしない（IFは不成立扱い）
    }

    // IF を先に評価（該当しない/外枠のみのIFはそのまま偽扱いで除去）
    $html2 = function_exists('macro__tpl_if')
        ? macro__tpl_if($html, $ifMap, $ifVals)
        : $html;

    // その後、通常のマクロ置換
    return preg_replace_callback('/\{\{\s*([A-Z_]+)([^}]*)\}\}/u',
        function ($m) use ($ctx) {
            $name  = strtoupper(trim($m[1]));
            $attrs = macro_parse_attrs((string)$m[2]);

            return match ($name) {
                'BASE_PATH'   => macro_base_path($ctx, $attrs),
                'SEARCH_FORM' => macro_search_form($ctx, $attrs),
                'LIST'        => macro_list($ctx, $attrs),
                'PAGER'       => macro_pager($ctx, $attrs),
                'RECENTS'    => macro_recents($ctx, $attrs),
                'CATEGORY_NAME' => macro_category_name($ctx, $attrs),
                'RELATED_ITEMS' => macro_related_items($ctx, $attrs),
                'FEATURE_ITEMS' => macro_feature_items($ctx, $attrs),                
                'CATEGORIES' => macro_categories($ctx, $attrs),
                'DETAIL'      => macro_detail($ctx, $attrs),
                'MAP'         => macro_map($ctx, $attrs),
                'CONTACT_FORM' => macro_contact_form($ctx, $attrs),
                'NEWS'       => macro_news($ctx, $attrs),
                default       => $m[0],
            };
        },
    $html2);
}
