<?php

declare(strict_types=1);

/**
 * 推薦技師標籤：主題（技師情報／服務）、標籤定義、會員點選紀錄。
 */

require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_masseur_rewards.php';

function fate_masseur_tag_tables_ddl(): array
{
    return [
        <<<'SQL'
CREATE TABLE IF NOT EXISTS `fate_masseur_tag_topics` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `slug` VARCHAR(32) NOT NULL,
  `title` VARCHAR(80) NOT NULL,
  `description` VARCHAR(2000) NOT NULL DEFAULT '',
  `allow_multiple` TINYINT NOT NULL DEFAULT 0,
  `sort` INT NOT NULL DEFAULT 0,
  `is_hidden` TINYINT NOT NULL DEFAULT 0,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_slug` (`slug`),
  KEY `idx_visible` (`is_hidden`, `sort`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
        <<<'SQL'
CREATE TABLE IF NOT EXISTS `fate_masseur_tags` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `topic_id` INT UNSIGNED NOT NULL,
  `label` VARCHAR(48) NOT NULL,
  `sort` INT NOT NULL DEFAULT 0,
  `is_hidden` TINYINT NOT NULL DEFAULT 0,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_topic` (`topic_id`, `is_hidden`, `sort`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
        <<<'SQL'
CREATE TABLE IF NOT EXISTS `fate_masseur_tag_picks` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `masseur_id` INT UNSIGNED NOT NULL,
  `tag_id` INT UNSIGNED NOT NULL,
  `member_id` INT UNSIGNED NOT NULL,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_pick` (`masseur_id`, `tag_id`, `member_id`),
  KEY `idx_masseur` (`masseur_id`),
  KEY `idx_tag` (`tag_id`),
  KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
    ];
}

function fate_masseur_tag_ensure_tables(PDO $pdo): void
{
    static $done = false;
    if ($done) {
        return;
    }
    try {
        foreach (fate_masseur_tag_tables_ddl() as $ddl) {
            $pdo->exec($ddl);
        }
        fate_masseur_tag_ensure_topic_description_column($pdo);
        fate_masseur_tag_seed_default_topics($pdo);
        $done = true;
    } catch (Throwable) {
        /* 無建表權限時略過 */
    }
}

function fate_masseur_tag_ensure_topic_description_column(PDO $pdo): void
{
    try {
        $st = $pdo->query("SHOW COLUMNS FROM `fate_masseur_tag_topics` LIKE 'description'");
        if ($st !== false && $st->fetch(PDO::FETCH_ASSOC) === false) {
            $pdo->exec("ALTER TABLE `fate_masseur_tag_topics` ADD COLUMN `description` VARCHAR(2000) NOT NULL DEFAULT '' AFTER `title`");
        }
    } catch (Throwable) {
        /* 略過 */
    }
}

function fate_masseur_tag_normalize_topic_description(string $raw): string
{
    $text = trim(str_replace(["\r\n", "\r"], "\n", $raw));
    if ($text === '') {
        return '';
    }
    if (function_exists('mb_substr')) {
        return mb_substr($text, 0, 2000, 'UTF-8');
    }
    if (strlen($text) > 2000) {
        return substr($text, 0, 2000);
    }

    return $text;
}

function fate_masseur_tag_seed_default_topics(PDO $pdo): void
{
    $defaults = [
        ['slug' => 'info', 'title' => '技師情報', 'allow_multiple' => 1, 'sort' => 10],
        ['slug' => 'service', 'title' => '服務', 'allow_multiple' => 0, 'sort' => 20],
    ];
    foreach ($defaults as $d) {
        try {
            $st = $pdo->prepare(
                'INSERT IGNORE INTO `fate_masseur_tag_topics` (`slug`, `title`, `allow_multiple`, `sort`) VALUES (?, ?, ?, ?)'
            );
            $st->execute([$d['slug'], $d['title'], $d['allow_multiple'], $d['sort']]);
        } catch (Throwable) {
            /* 略過 */
        }
    }
}

/** @return list<array<string,mixed>> */
function fate_masseur_tag_topics_all(PDO $pdo, bool $includeHidden = false): array
{
    fate_masseur_tag_ensure_tables($pdo);
    $sql = 'SELECT * FROM `fate_masseur_tag_topics`';
    if (!$includeHidden) {
        $sql .= ' WHERE `is_hidden` = 0';
    }
    $sql .= ' ORDER BY `sort` ASC, `id` ASC';
    try {
        $rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);

        return is_array($rows) ? $rows : [];
    } catch (Throwable) {
        return [];
    }
}

/** @return array<string,mixed>|null */
function fate_masseur_tag_topic_by_id(PDO $pdo, int $id): ?array
{
    if ($id <= 0) {
        return null;
    }
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare('SELECT * FROM `fate_masseur_tag_topics` WHERE `id` = ? LIMIT 1');
        $st->execute([$id]);
        $row = $st->fetch(PDO::FETCH_ASSOC);

        return is_array($row) ? $row : null;
    } catch (Throwable) {
        return null;
    }
}

/**
 * @return array{ok:bool,error:?string}
 */
function fate_masseur_tag_topic_save(PDO $pdo, int $id, string $title, bool $allowMultiple, bool $isHidden, int $sort, string $description = ''): array
{
    fate_masseur_tag_ensure_tables($pdo);
    $title = trim($title);
    $description = fate_masseur_tag_normalize_topic_description($description);
    if ($id <= 0 || $title === '') {
        return ['ok' => false, 'error' => '參數錯誤。'];
    }
    if (function_exists('mb_substr')) {
        $title = mb_substr($title, 0, 80);
    }
    try {
        $pdo->prepare(
            'UPDATE `fate_masseur_tag_topics` SET `title` = ?, `description` = ?, `allow_multiple` = ?, `is_hidden` = ?, `sort` = ? WHERE `id` = ?'
        )->execute([$title, $description, $allowMultiple ? 1 : 0, $isHidden ? 1 : 0, $sort, $id]);

        return ['ok' => true, 'error' => null];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '儲存失敗：' . $e->getMessage()];
    }
}

/** @return list<array<string,mixed>> */
function fate_masseur_tags_for_topic(PDO $pdo, int $topicId, bool $includeHidden = false): array
{
    fate_masseur_tag_ensure_tables($pdo);
    if ($topicId <= 0) {
        return [];
    }
    $sql = 'SELECT t.*, (
        SELECT COUNT(*) FROM `fate_masseur_tag_picks` p WHERE p.`tag_id` = t.`id`
    ) AS `pick_total`
    FROM `fate_masseur_tags` t WHERE t.`topic_id` = ?';
    if (!$includeHidden) {
        $sql .= ' AND t.`is_hidden` = 0';
    }
    $sql .= ' ORDER BY t.`sort` ASC, t.`id` ASC';
    try {
        $st = $pdo->prepare($sql);
        $st->execute([$topicId]);
        $rows = $st->fetchAll(PDO::FETCH_ASSOC);

        return is_array($rows) ? $rows : [];
    } catch (Throwable) {
        return [];
    }
}

/** @return array<string,mixed>|null */
function fate_masseur_tag_by_id(PDO $pdo, int $tagId): ?array
{
    if ($tagId <= 0) {
        return null;
    }
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare(
            'SELECT t.*, tp.`slug` AS `topic_slug`, tp.`title` AS `topic_title`, tp.`allow_multiple`
             FROM `fate_masseur_tags` t
             INNER JOIN `fate_masseur_tag_topics` tp ON tp.`id` = t.`topic_id`
             WHERE t.`id` = ? LIMIT 1'
        );
        $st->execute([$tagId]);
        $row = $st->fetch(PDO::FETCH_ASSOC);

        return is_array($row) ? $row : null;
    } catch (Throwable) {
        return null;
    }
}

/**
 * @return array{ok:bool,error:?string,id:?int}
 */
function fate_masseur_tag_save(PDO $pdo, int $topicId, string $label, int $sort = 0, int $tagId = 0): array
{
    fate_masseur_tag_ensure_tables($pdo);
    $label = trim($label);
    if ($topicId <= 0 || $label === '') {
        return ['ok' => false, 'error' => '請填寫標籤名稱。', 'id' => null];
    }
    if (function_exists('mb_substr')) {
        $label = mb_substr($label, 0, 48);
    }
    if (fate_masseur_tag_topic_by_id($pdo, $topicId) === null) {
        return ['ok' => false, 'error' => '找不到主題。', 'id' => null];
    }
    try {
        if ($tagId > 0) {
            $pdo->prepare('UPDATE `fate_masseur_tags` SET `label` = ?, `sort` = ? WHERE `id` = ? AND `topic_id` = ?')
                ->execute([$label, $sort, $tagId, $topicId]);

            return ['ok' => true, 'error' => null, 'id' => $tagId];
        }
        $pdo->prepare('INSERT INTO `fate_masseur_tags` (`topic_id`, `label`, `sort`) VALUES (?, ?, ?)')
            ->execute([$topicId, $label, $sort]);

        return ['ok' => true, 'error' => null, 'id' => (int) $pdo->lastInsertId()];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '儲存失敗：' . $e->getMessage(), 'id' => null];
    }
}

/**
 * @return array{ok:bool,error:?string}
 */
function fate_masseur_tag_set_hidden(PDO $pdo, int $tagId, bool $hidden): array
{
    fate_masseur_tag_ensure_tables($pdo);
    if ($tagId <= 0) {
        return ['ok' => false, 'error' => '參數錯誤。'];
    }
    try {
        $pdo->prepare('UPDATE `fate_masseur_tags` SET `is_hidden` = ? WHERE `id` = ?')->execute([$hidden ? 1 : 0, $tagId]);

        return ['ok' => true, 'error' => null];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '更新失敗：' . $e->getMessage()];
    }
}

/**
 * 刪除標籤定義與所有點選紀錄，並扣回相關 N 幣。
 *
 * @return array{ok:bool,error:?string}
 */
function fate_masseur_tag_admin_delete(PDO $pdo, int $tagId): array
{
    fate_masseur_tag_ensure_tables($pdo);
    if ($tagId <= 0) {
        return ['ok' => false, 'error' => '參數錯誤。'];
    }
    try {
        $st = $pdo->prepare('SELECT `id` FROM `fate_masseur_tag_picks` WHERE `tag_id` = ?');
        $st->execute([$tagId]);
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $pickId) {
            fate_masseur_tag_pick_revert_reward($pdo, (int) $pickId);
        }
        $pdo->prepare('DELETE FROM `fate_masseur_tag_picks` WHERE `tag_id` = ?')->execute([$tagId]);
        $pdo->prepare('DELETE FROM `fate_masseur_tags` WHERE `id` = ?')->execute([$tagId]);
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '刪除失敗：' . $e->getMessage()];
    }

    return ['ok' => true, 'error' => null];
}

function fate_masseur_tag_pick_has_ledger_reward(PDO $pdo, int $pickId): bool
{
    if ($pickId <= 0) {
        return false;
    }
    fate_reward_ledger_ensure_table($pdo);
    try {
        $st = $pdo->prepare(
            'SELECT 1 FROM `fate_reward_ledger` WHERE `action` = \'tag_pick\' AND `ref_id` = ? AND `reverted` = 0 LIMIT 1'
        );
        $st->execute([$pickId]);

        return $st->fetchColumn() !== false;
    } catch (Throwable) {
        return false;
    }
}

/** 僅在該次點選確實發過標籤獎勵時才扣回（超過獎勵上限的點選無帳本紀錄，收回時不扣幣）。 */
function fate_masseur_tag_pick_revert_reward(PDO $pdo, int $pickId): void
{
    if ($pickId <= 0 || !fate_masseur_tag_pick_has_ledger_reward($pdo, $pickId)) {
        return;
    }
    fate_masseur_reward_revert($pdo, 'tag_pick', $pickId);
}

/** 同會員對同一技師已獲得且未扣回的標籤獎勵次數。 */
function fate_masseur_tag_reward_count_for_masseur(PDO $pdo, int $memberId, int $masseurId): int
{
    if ($memberId <= 0 || $masseurId <= 0) {
        return 0;
    }
    fate_reward_ledger_ensure_table($pdo);
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare(
            'SELECT COUNT(*) FROM `fate_reward_ledger` rl
             INNER JOIN `fate_masseur_tag_picks` p ON p.`id` = rl.`ref_id`
             WHERE rl.`action` = \'tag_pick\' AND rl.`member_id` = ? AND rl.`reverted` = 0 AND p.`masseur_id` = ?'
        );
        $st->execute([$memberId, $masseurId]);

        return (int) $st->fetchColumn();
    } catch (Throwable) {
        return 0;
    }
}

/** 自指定時間起，會員已獲標籤獎勵的不同技師數（未扣回）。 */
function fate_masseur_tag_reward_distinct_masseurs_since(PDO $pdo, int $memberId, string $since): int
{
    if ($memberId <= 0 || $since === '') {
        return 0;
    }
    fate_reward_ledger_ensure_table($pdo);
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare(
            'SELECT COUNT(DISTINCT p.`masseur_id`) FROM `fate_reward_ledger` rl
             INNER JOIN `fate_masseur_tag_picks` p ON p.`id` = rl.`ref_id`
             WHERE rl.`action` = \'tag_pick\' AND rl.`member_id` = ? AND rl.`reverted` = 0 AND rl.`created_at` >= ?'
        );
        $st->execute([$memberId, $since]);

        return (int) $st->fetchColumn();
    } catch (Throwable) {
        return 0;
    }
}

/** 自指定時間起，會員是否已在該技師獲得過標籤獎勵（未扣回）。 */
function fate_masseur_tag_has_reward_on_masseur_since(PDO $pdo, int $memberId, int $masseurId, string $since): bool
{
    if ($memberId <= 0 || $masseurId <= 0 || $since === '') {
        return false;
    }
    fate_reward_ledger_ensure_table($pdo);
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare(
            'SELECT 1 FROM `fate_reward_ledger` rl
             INNER JOIN `fate_masseur_tag_picks` p ON p.`id` = rl.`ref_id`
             WHERE rl.`action` = \'tag_pick\' AND rl.`member_id` = ? AND rl.`reverted` = 0
               AND p.`masseur_id` = ? AND rl.`created_at` >= ?
             LIMIT 1'
        );
        $st->execute([$memberId, $masseurId, $since]);

        return $st->fetchColumn() !== false;
    } catch (Throwable) {
        return false;
    }
}

function fate_masseur_tag_can_grant_reward(PDO $pdo, int $memberId, int $masseurId): bool
{
    $cfg = fate_masseur_rewards_settings();
    $maxPerMasseur = (int) ($cfg['tag_reward_max_per_masseur'] ?? 5);
    if ($maxPerMasseur > 0 && fate_masseur_tag_reward_count_for_masseur($pdo, $memberId, $masseurId) >= $maxPerMasseur) {
        return false;
    }

    $maxPerDay = (int) ($cfg['tag_reward_max_masseurs_per_day'] ?? 10);
    if ($maxPerDay <= 0) {
        return true;
    }

    require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_store_masseurs.php';
    $dayStart = fate_masseur_day_start();
    if (fate_masseur_tag_has_reward_on_masseur_since($pdo, $memberId, $masseurId, $dayStart)) {
        return true;
    }

    return fate_masseur_tag_reward_distinct_masseurs_since($pdo, $memberId, $dayStart) < $maxPerDay;
}

/**
 * 會員對技師標籤點選／取消（toggle）。
 *
 * @return array{ok:bool,error:?string,picked:bool,pick_count:int,reward_granted:bool,reward_skipped_cap:bool}
 */
function fate_masseur_tag_pick_toggle(
    PDO $pdo,
    int $masseurId,
    int $tagId,
    int $memberId,
    string $username = ''
): array {
    fate_masseur_tag_ensure_tables($pdo);
    require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_store_masseurs.php';

    if ($masseurId <= 0 || $tagId <= 0 || $memberId <= 0) {
        return ['ok' => false, 'error' => '參數錯誤。', 'picked' => false, 'pick_count' => 0];
    }
    $masseur = fate_masseur_get($pdo, $masseurId);
    if ($masseur === null || (int) ($masseur['is_hidden'] ?? 0) === 1) {
        return ['ok' => false, 'error' => '找不到技師。', 'picked' => false, 'pick_count' => 0];
    }
    $tag = fate_masseur_tag_by_id($pdo, $tagId);
    if ($tag === null || (int) ($tag['is_hidden'] ?? 0) === 1) {
        return ['ok' => false, 'error' => '找不到標籤。', 'picked' => false, 'pick_count' => 0];
    }
    $topicId = (int) ($tag['topic_id'] ?? 0);
    $allowMultiple = (int) ($tag['allow_multiple'] ?? 0) === 1;

    try {
        $st = $pdo->prepare(
            'SELECT `id` FROM `fate_masseur_tag_picks` WHERE `masseur_id` = ? AND `tag_id` = ? AND `member_id` = ? LIMIT 1'
        );
        $st->execute([$masseurId, $tagId, $memberId]);
        $existingId = $st->fetchColumn();

        if ($existingId !== false) {
            $pickId = (int) $existingId;
            fate_masseur_tag_pick_revert_reward($pdo, $pickId);
            $pdo->prepare('DELETE FROM `fate_masseur_tag_picks` WHERE `id` = ?')->execute([$pickId]);
            $picked = false;
        } else {
            if (!$allowMultiple) {
                $others = $pdo->prepare(
                    'SELECT p.`id` FROM `fate_masseur_tag_picks` p
                     INNER JOIN `fate_masseur_tags` tg ON tg.`id` = p.`tag_id`
                     WHERE p.`masseur_id` = ? AND p.`member_id` = ? AND tg.`topic_id` = ?'
                );
                $others->execute([$masseurId, $memberId, $topicId]);
                foreach ($others->fetchAll(PDO::FETCH_COLUMN) as $oid) {
                    $oid = (int) $oid;
                    fate_masseur_tag_pick_revert_reward($pdo, $oid);
                    $pdo->prepare('DELETE FROM `fate_masseur_tag_picks` WHERE `id` = ?')->execute([$oid]);
                }
            }
            $pdo->prepare(
                'INSERT INTO `fate_masseur_tag_picks` (`masseur_id`, `tag_id`, `member_id`) VALUES (?, ?, ?)'
            )->execute([$masseurId, $tagId, $memberId]);
            $pickId = (int) $pdo->lastInsertId();
            $label = (string) ($tag['label'] ?? '');
            $rewardGranted = false;
            $rewardSkippedCap = false;
            if (fate_masseur_tag_can_grant_reward($pdo, $memberId, $masseurId)) {
                $rewardGranted = fate_masseur_reward_grant($pdo, $memberId, 'tag_pick', $pickId, $username, '技師標籤：' . $label) > 0;
            } else {
                $rewardSkippedCap = (int) (fate_masseur_rewards_settings()['reward_tag'] ?? 0) > 0;
            }
            $picked = true;
        }

        $cntSt = $pdo->prepare('SELECT COUNT(*) FROM `fate_masseur_tag_picks` WHERE `masseur_id` = ? AND `tag_id` = ?');
        $cntSt->execute([$masseurId, $tagId]);
        $pickCount = (int) $cntSt->fetchColumn();

        return [
            'ok' => true,
            'error' => null,
            'picked' => $picked,
            'pick_count' => $pickCount,
            'reward_granted' => $rewardGranted ?? false,
            'reward_skipped_cap' => $rewardSkippedCap ?? false,
        ];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '操作失敗：' . $e->getMessage(), 'picked' => false, 'pick_count' => 0];
    }
}

/**
 * 刪除技師時一併清除標籤點選並扣回獎勵。
 */
function fate_masseur_tag_picks_delete_for_masseur(PDO $pdo, int $masseurId): void
{
    if ($masseurId <= 0) {
        return;
    }
    fate_masseur_tag_ensure_tables($pdo);
    try {
        $st = $pdo->prepare('SELECT `id` FROM `fate_masseur_tag_picks` WHERE `masseur_id` = ?');
        $st->execute([$masseurId]);
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $pickId) {
            fate_masseur_tag_pick_revert_reward($pdo, (int) $pickId);
        }
        $pdo->prepare('DELETE FROM `fate_masseur_tag_picks` WHERE `masseur_id` = ?')->execute([$masseurId]);
    } catch (Throwable) {
        /* 略過 */
    }
}

/**
 * 管理員刪除單筆點選紀錄。
 *
 * @return array{ok:bool,error:?string}
 */
function fate_masseur_tag_pick_admin_delete(PDO $pdo, int $pickId): array
{
    fate_masseur_tag_ensure_tables($pdo);
    if ($pickId <= 0) {
        return ['ok' => false, 'error' => '參數錯誤。'];
    }
    try {
        fate_masseur_tag_pick_revert_reward($pdo, $pickId);
        $pdo->prepare('DELETE FROM `fate_masseur_tag_picks` WHERE `id` = ?')->execute([$pickId]);
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '刪除失敗：' . $e->getMessage()];
    }

    return ['ok' => true, 'error' => null];
}

/**
 * 前台：依技師 id 列表載入標籤主題、各標籤在該技師上的點選數與目前會員是否已選。
 *
 * @param list<int> $masseurIds
 * @return array{
 *   topics: list<array{id:int,slug:string,title:string,description:string,allow_multiple:bool,tags:list<array{id:int,label:string}>}>,
 *   masseurs: array<int, array{viewer: array<int,bool>, counts: array<int,int>}>
 * }
 */
function fate_masseur_tag_store_payload(PDO $pdo, array $masseurIds, int $viewerMemberId = 0): array
{
    fate_masseur_tag_ensure_tables($pdo);
    $ids = [];
    foreach ($masseurIds as $mid) {
        $mid = (int) $mid;
        if ($mid > 0) {
            $ids[$mid] = true;
        }
    }
    $masseurIds = array_keys($ids);
    $out = ['topics' => [], 'masseurs' => []];
    foreach ($masseurIds as $mid) {
        $out['masseurs'][$mid] = ['viewer' => [], 'counts' => []];
    }

    $topicRows = fate_masseur_tag_topics_all($pdo, false);
    if ($topicRows === []) {
        return $out;
    }

    foreach ($topicRows as $tp) {
        $tid = (int) ($tp['id'] ?? 0);
        $tags = fate_masseur_tags_for_topic($pdo, $tid, false);
        if ($tags === []) {
            continue;
        }
        $tagList = [];
        foreach ($tags as $tg) {
            $tagList[] = [
                'id' => (int) ($tg['id'] ?? 0),
                'label' => (string) ($tg['label'] ?? ''),
            ];
        }
        $out['topics'][] = [
            'id' => $tid,
            'slug' => (string) ($tp['slug'] ?? ''),
            'title' => (string) ($tp['title'] ?? ''),
            'description' => fate_masseur_tag_normalize_topic_description((string) ($tp['description'] ?? '')),
            'allow_multiple' => (int) ($tp['allow_multiple'] ?? 0) === 1,
            'tags' => $tagList,
        ];
    }

    if ($masseurIds === [] || $out['topics'] === []) {
        return $out;
    }

    $place = implode(',', array_fill(0, count($masseurIds), '?'));
    try {
        $cntSt = $pdo->prepare(
            "SELECT `masseur_id`, `tag_id`, COUNT(*) AS `c`
             FROM `fate_masseur_tag_picks`
             WHERE `masseur_id` IN ($place)
             GROUP BY `masseur_id`, `tag_id`"
        );
        $cntSt->execute($masseurIds);
        foreach ($cntSt->fetchAll(PDO::FETCH_ASSOC) as $r) {
            $mid = (int) ($r['masseur_id'] ?? 0);
            $tg = (int) ($r['tag_id'] ?? 0);
            if ($mid > 0 && $tg > 0 && isset($out['masseurs'][$mid])) {
                $out['masseurs'][$mid]['counts'][$tg] = (int) ($r['c'] ?? 0);
            }
        }
        if ($viewerMemberId > 0) {
            $vwSt = $pdo->prepare(
                "SELECT `masseur_id`, `tag_id` FROM `fate_masseur_tag_picks`
                 WHERE `member_id` = ? AND `masseur_id` IN ($place)"
            );
            $vwSt->execute(array_merge([$viewerMemberId], $masseurIds));
            foreach ($vwSt->fetchAll(PDO::FETCH_ASSOC) as $r) {
                $mid = (int) ($r['masseur_id'] ?? 0);
                $tg = (int) ($r['tag_id'] ?? 0);
                if ($mid > 0 && $tg > 0 && isset($out['masseurs'][$mid])) {
                    $out['masseurs'][$mid]['viewer'][$tg] = true;
                }
            }
        }
    } catch (Throwable) {
        /* 略過 */
    }

    return $out;
}

/** @return list<array<string,mixed>> */
function fate_masseur_tag_picks_recent(PDO $pdo, int $limit = 50): array
{
    fate_masseur_tag_ensure_tables($pdo);
    $limit = max(1, min(200, $limit));
    try {
        $st = $pdo->query(
            'SELECT p.*, m.`name` AS `masseur_name`, m.`spa_store_id`, tg.`label` AS `tag_label`,
                    tp.`title` AS `topic_title`
             FROM `fate_masseur_tag_picks` p
             INNER JOIN `fate_store_masseurs` m ON m.`id` = p.`masseur_id`
             INNER JOIN `fate_masseur_tags` tg ON tg.`id` = p.`tag_id`
             INNER JOIN `fate_masseur_tag_topics` tp ON tp.`id` = tg.`topic_id`
             ORDER BY p.`id` DESC
             LIMIT ' . $limit
        );
        $rows = $st->fetchAll(PDO::FETCH_ASSOC);

        return is_array($rows) ? $rows : [];
    } catch (Throwable) {
        return [];
    }
}

/**
 * 技師卡標籤預覽數量（依點選次數排序後顯示前 N 個，其餘收折）。
 */
function fate_masseur_tag_preview_limit(): int
{
    return 7;
}

/**
 * @param list<array{id:int,label:string}> $tags
 * @param array{viewer: array<int,bool>, counts: array<int,int>} $mData
 * @return list<array{id:int,label:string}>
 */
function fate_masseur_tag_sort_by_count(array $tags, array $mData): array
{
    $sorted = $tags;
    usort($sorted, static function (array $a, array $b) use ($mData): int {
        $idA = (int) ($a['id'] ?? 0);
        $idB = (int) ($b['id'] ?? 0);
        $cntA = (int) ($mData['counts'][$idA] ?? 0);
        $cntB = (int) ($mData['counts'][$idB] ?? 0);
        if ($cntA !== $cntB) {
            return $cntB <=> $cntA;
        }

        return strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? ''));
    });

    return $sorted;
}

/**
 * @param array{
 *   topics: list<array{id:int,slug:string,title:string,description:string,allow_multiple:bool,tags:list<array{id:int,label:string}>}>,
 *   masseurs: array<int, array{viewer: array<int,bool>, counts: array<int,int>}>
 * } $payload
 * @param array{viewer: array<int,bool>, counts: array<int,int>} $mData
 * @return list<array{tag:array{id:int,label:string},topic:array<string,mixed>,count:int,picked:bool}>
 */
function fate_masseur_tag_flat_for_masseur(array $payload, array $mData): array
{
    $flat = [];
    foreach ($payload['topics'] as $topic) {
        foreach ($topic['tags'] ?? [] as $tag) {
            $tagId = (int) ($tag['id'] ?? 0);
            if ($tagId <= 0) {
                continue;
            }
            $flat[] = [
                'tag' => $tag,
                'topic' => $topic,
                'count' => (int) ($mData['counts'][$tagId] ?? 0),
                'picked' => !empty($mData['viewer'][$tagId]),
            ];
        }
    }
    usort($flat, static function (array $a, array $b): int {
        if ($a['count'] !== $b['count']) {
            return $b['count'] <=> $a['count'];
        }

        return strcmp((string) ($a['tag']['label'] ?? ''), (string) ($b['tag']['label'] ?? ''));
    });

    return $flat;
}

/**
 * @param list<array{tag:array{id:int,label:string},topic:array<string,mixed>,count:int,picked:bool}> $flat
 * @return list<array{tag:array{id:int,label:string},topic:array<string,mixed>,count:int,picked:bool}>
 */
function fate_masseur_tag_preview_items(array $flat, int $limit): array
{
    if ($flat === [] || count($flat) <= $limit) {
        return $flat;
    }

    $preview = array_slice($flat, 0, $limit);
    $previewIds = [];
    foreach ($preview as $item) {
        $previewIds[(int) ($item['tag']['id'] ?? 0)] = true;
    }

    foreach ($flat as $item) {
        if (empty($item['picked'])) {
            continue;
        }
        $tagId = (int) ($item['tag']['id'] ?? 0);
        if ($tagId <= 0 || isset($previewIds[$tagId])) {
            continue;
        }
        $replaceIdx = null;
        $minCnt = PHP_INT_MAX;
        foreach ($preview as $i => $p) {
            if (!empty($p['picked'])) {
                continue;
            }
            if ($p['count'] < $minCnt) {
                $minCnt = $p['count'];
                $replaceIdx = $i;
            }
        }
        if ($replaceIdx === null) {
            continue;
        }
        unset($previewIds[(int) ($preview[$replaceIdx]['tag']['id'] ?? 0)]);
        $preview[$replaceIdx] = $item;
        $previewIds[$tagId] = true;
    }

    usort($preview, static function (array $a, array $b): int {
        if ($a['count'] !== $b['count']) {
            return $b['count'] <=> $a['count'];
        }

        return strcmp((string) ($a['tag']['label'] ?? ''), (string) ($b['tag']['label'] ?? ''));
    });

    return $preview;
}

function fate_masseur_tag_render_chip(
    array $tag,
    int $count,
    bool $picked,
    int $masseurId,
    int $storeId,
    bool $verified,
    string $csrf,
    string $apiAction,
    ?string $returnTo
): void {
    $tagId = (int) ($tag['id'] ?? 0);
    if ($tagId <= 0) {
        return;
    }
    if ($verified) {
        ?>
        <form method="post" action="<?= htmlspecialchars($apiAction, ENT_QUOTES, 'UTF-8') ?>" class="masseur-tag-form">
            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>">
            <input type="hidden" name="action" value="tag_pick">
            <input type="hidden" name="store_id" value="<?= $storeId ?>">
            <input type="hidden" name="masseur_id" value="<?= $masseurId ?>">
            <input type="hidden" name="tag_id" value="<?= $tagId ?>">
            <?php if ($returnTo !== null && $returnTo !== ''): ?>
                <input type="hidden" name="return_to" value="<?= htmlspecialchars($returnTo, ENT_QUOTES, 'UTF-8') ?>">
            <?php endif; ?>
            <button type="submit" class="masseur-tag<?= $picked ? ' is-active' : '' ?>">
                <?= htmlspecialchars((string) ($tag['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
                <span class="masseur-tag__n"><?= $count ?></span>
            </button>
        </form>
        <?php
        return;
    }
    ?>
    <span class="masseur-tag is-static<?= $count > 0 ? ' has-count' : '' ?>">
        <?= htmlspecialchars((string) ($tag['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
        <?php if ($count > 0): ?><span class="masseur-tag__n"><?= $count ?></span><?php endif; ?>
    </span>
    <?php
}

/**
 * @param array{id:int,slug:string,title:string,description:string,allow_multiple:bool,tags:list<array{id:int,label:string}>} $topic
 * @param array{viewer: array<int,bool>, counts: array<int,int>} $mData
 */
function fate_masseur_tag_render_topic_group(
    array $topic,
    int $masseurId,
    int $storeId,
    array $mData,
    bool $verified,
    string $csrf,
    string $apiAction,
    ?string $returnTo
): void {
    $tags = $topic['tags'] ?? [];
    if ($tags === []) {
        return;
    }
    $tags = fate_masseur_tag_sort_by_count($tags, $mData);
    ?>
    <div class="masseur-tags__group">
        <div class="masseur-tags__head">
            <span class="masseur-tags__topic"><?= htmlspecialchars((string) ($topic['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?><?= !empty($topic['allow_multiple']) ? '' : '（單選）' ?></span>
            <?php
            $topicDesc = trim((string) ($topic['description'] ?? ''));
            if ($topicDesc !== ''):
                $helpId = 'masseur-tags-help-' . $masseurId . '-' . (int) ($topic['id'] ?? 0);
            ?>
                <details class="masseur-tags__help">
                    <summary class="masseur-tags__help-summary">說明</summary>
                    <div class="masseur-tags__desc" id="<?= htmlspecialchars($helpId, ENT_QUOTES, 'UTF-8') ?>"><?= nl2br(htmlspecialchars($topicDesc, ENT_QUOTES, 'UTF-8')) ?></div>
                </details>
            <?php endif; ?>
        </div>
        <div class="masseur-tags__list">
            <?php foreach ($tags as $tag): ?>
                <?php
                $tagId = (int) ($tag['id'] ?? 0);
                fate_masseur_tag_render_chip(
                    $tag,
                    (int) ($mData['counts'][$tagId] ?? 0),
                    !empty($mData['viewer'][$tagId]),
                    $masseurId,
                    $storeId,
                    $verified,
                    $csrf,
                    $apiAction,
                    $returnTo
                );
                ?>
            <?php endforeach; ?>
        </div>
    </div>
    <?php
}

/**
 * 輸出技師卡上的標籤區塊（依主題分組）。
 *
 * @param array{
 *   topics: list<array{id:int,slug:string,title:string,description:string,allow_multiple:bool,tags:list<array{id:int,label:string}>}>,
 *   masseurs: array<int, array{viewer: array<int,bool>, counts: array<int,int>}>
 * } $payload
 */
function fate_masseur_tag_render_row(
    int $masseurId,
    int $storeId,
    array $payload,
    bool $verified,
    string $csrf,
    string $apiAction = 'api/masseur_action.php',
    ?string $returnTo = null
): void {
    if ($payload['topics'] === [] || !isset($payload['masseurs'][$masseurId])) {
        return;
    }
    $mData = $payload['masseurs'][$masseurId];
    $flat = fate_masseur_tag_flat_for_masseur($payload, $mData);
    $totalTags = count($flat);
    $previewLimit = fate_masseur_tag_preview_limit();
    $collapsible = $totalTags > $previewLimit;
    $rowItems = $collapsible ? fate_masseur_tag_preview_items($flat, $previewLimit) : $flat;
    ?>
    <div class="masseur-tags<?= $collapsible ? ' masseur-tags--collapsible' : '' ?>" id="masseur-tags-<?= $masseurId ?>">
        <div class="masseur-tags__bar">
            <?php foreach ($rowItems as $item): ?>
                <?php fate_masseur_tag_render_chip(
                    $item['tag'],
                    $item['count'],
                    $item['picked'],
                    $masseurId,
                    $storeId,
                    $verified,
                    $csrf,
                    $apiAction,
                    $returnTo
                ); ?>
            <?php endforeach; ?>
            <?php if ($collapsible): ?>
                <details class="masseur-tags__more">
                    <summary class="masseur-tags__more-summary">查看全部標籤（<?= $totalTags ?>）</summary>
                    <div class="masseur-tags__all">
                        <?php foreach ($payload['topics'] as $topic): ?>
                            <?php fate_masseur_tag_render_topic_group(
                                $topic,
                                $masseurId,
                                $storeId,
                                $mData,
                                $verified,
                                $csrf,
                                $apiAction,
                                $returnTo
                            ); ?>
                        <?php endforeach; ?>
                    </div>
                </details>
            <?php endif; ?>
        </div>
    </div>
    <?php
}
