<?php

declare(strict_types=1);

const FATE_STORE_SEARCH_MODE_PRECISE = 'precise';
const FATE_STORE_SEARCH_MODE_FUZZY = 'fuzzy';
const FATE_STORE_SEARCH_MODE_NONE = 'none';

/**
 * LIKE 特殊字元跳脫。
 */
function fate_store_search_like_escape(?string $value): string
{
    return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], (string) $value);
}

/**
 * 將搜尋字串切為詞組（空白、全形空白、常見標點）。
 *
 * @return list<string>
 */
function fate_store_search_tokens(string $q): array
{
    $q = trim($q);
    if ($q === '') {
        return [];
    }

    $parts = preg_split('/[\s　,，、·•|｜\-—]+/u', $q, -1, PREG_SPLIT_NO_EMPTY);
    if (!is_array($parts)) {
        return [$q];
    }

    $tokens = [];
    foreach ($parts as $part) {
        $part = trim((string) $part);
        if ($part === '' || in_array($part, $tokens, true)) {
            continue;
        }
        $tokens[] = $part;
    }

    return $tokens !== [] ? $tokens : [$q];
}

/**
 * 單一詞是否命中可搜尋欄位（店名、地址、區域、電話）。
 *
 * @param array<string,string|int> $params
 */
function fate_store_search_term_sql(string $paramKey, string $term, array &$params): string
{
    $like = '%' . fate_store_search_like_escape($term) . '%';
    $fields = [
        ':' . $paramKey . '_name' => 'store_name',
        ':' . $paramKey . '_addr' => 'display_address',
        ':' . $paramKey . '_dist' => 'district_name',
        ':' . $paramKey . '_city' => 'city_name',
        ':' . $paramKey . '_phone' => 'phone',
    ];
    foreach ($fields as $key => $col) {
        $params[$key] = $like;
    }

    $parts = [];
    foreach ($fields as $key => $col) {
        $parts[] = $col . ' LIKE ' . $key;
    }

    $digits = preg_replace('/\D/u', '', $term) ?? '';
    if (strlen($digits) >= 4) {
        $digitKey = ':' . $paramKey . '_pd';
        $params[$digitKey] = '%' . $digits . '%';
        $parts[] = "REPLACE(REPLACE(REPLACE(REPLACE(phone, '-', ''), ' ', ''), '(', ''), ')', '') LIKE " . $digitKey;
    }

    return '(' . implode(' OR ', $parts) . ')';
}

/**
 * 字元間隔模糊（例：「台北」→ %台%北%）。
 *
 * @param array<string,string|int> $params
 */
function fate_store_search_scatter_sql(string $paramKey, string $term, array &$params): string
{
    $chars = preg_split('//u', $term, -1, PREG_SPLIT_NO_EMPTY);
    if (!is_array($chars) || $chars === []) {
        return '0';
    }

    $pattern = '%' . implode('%', array_map('fate_store_search_like_escape', $chars)) . '%';
    $fields = [
        ':' . $paramKey . '_sn' => 'store_name',
        ':' . $paramKey . '_sa' => 'display_address',
        ':' . $paramKey . '_sd' => 'district_name',
        ':' . $paramKey . '_sc' => 'city_name',
        ':' . $paramKey . '_sp' => 'phone',
    ];
    foreach ($fields as $key => $col) {
        $params[$key] = $pattern;
    }

    $parts = [];
    foreach ($fields as $key => $col) {
        $parts[] = $col . ' LIKE ' . $key;
    }

    return '(' . implode(' OR ', $parts) . ')';
}

/**
 * 精準：整句命中，或多詞時每個詞都須命中（AND）。
 *
 * @param list<string> $tokens
 * @param array<string,string|int> $params
 */
function fate_store_search_build_precise(array $tokens, string $fullQ, array &$params): string
{
    if ($tokens === []) {
        return '';
    }

    if (count($tokens) === 1) {
        return fate_store_search_term_sql('sq_p0', $tokens[0], $params);
    }

    $parts = [];
    foreach ($tokens as $i => $token) {
        $parts[] = fate_store_search_term_sql('sq_p' . $i, $token, $params);
    }

    return '(' . implode(' AND ', $parts) . ')';
}

/**
 * 模糊：任一词命中（OR）；单词时另试字元间隔。
 *
 * @param list<string> $tokens
 * @param array<string,string|int> $params
 */
function fate_store_search_build_fuzzy(array $tokens, string $fullQ, array &$params): string
{
    if ($tokens === []) {
        return '';
    }

    $groups = [];
    foreach ($tokens as $i => $token) {
        $groups[] = fate_store_search_term_sql('sq_f' . $i, $token, $params);
    }

    if (count($tokens) === 1) {
        $term = $tokens[0];
        if (fate_store_search_strlen($term) >= 2) {
            $groups[] = fate_store_search_scatter_sql('sq_fs0', $term, $params);
        }
    }

    return '(' . implode(' OR ', $groups) . ')';
}

/**
 * @return list<string>
 */
function fate_store_search_utf8_chars(string $s): array
{
    $chars = preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY);

    return is_array($chars) ? $chars : [];
}

function fate_store_search_strlen(string $s): int
{
    return function_exists('mb_strlen') ? (int) mb_strlen($s) : strlen($s);
}

/**
 * @return list<string>
 */
function fate_store_search_bigrams(string $s): array
{
    $chars = fate_store_search_utf8_chars($s);
    $n = count($chars);
    if ($n < 2) {
        return $n === 1 ? [$chars[0]] : [];
    }

    $bigrams = [];
    for ($i = 0; $i < $n - 1; $i++) {
        $bigrams[] = $chars[$i] . $chars[$i + 1];
    }

    return $bigrams;
}

function fate_store_search_bigram_similarity(string $a, string $b): float
{
    if ($a === '' || $b === '') {
        return 0.0;
    }
    if ($a === $b) {
        return 1.0;
    }

    $ba = fate_store_search_bigrams($a);
    $bb = fate_store_search_bigrams($b);
    if ($ba === [] || $bb === []) {
        return (function_exists('mb_strpos') ? mb_strpos($b, $a) : strpos($b, $a)) !== false
            || (function_exists('mb_strpos') ? mb_strpos($a, $b) : strpos($a, $b)) !== false
            ? 0.55
            : 0.0;
    }

    $inter = count(array_intersect($ba, $bb));
    $union = count(array_unique(array_merge($ba, $bb)));

    return $union > 0 ? $inter / $union : 0.0;
}

function fate_store_search_unicode_levenshtein(string $a, string $b): int
{
    $ca = fate_store_search_utf8_chars($a);
    $cb = fate_store_search_utf8_chars($b);
    $m = count($ca);
    $n = count($cb);
    if ($m === 0) {
        return $n;
    }
    if ($n === 0) {
        return $m;
    }
    if ($m > 48 || $n > 48) {
        return max($m, $n);
    }

    $dp = range(0, $n);
    for ($i = 1; $i <= $m; $i++) {
        $prev = $dp[0];
        $dp[0] = $i;
        for ($j = 1; $j <= $n; $j++) {
            $tmp = $dp[$j];
            $cost = ($ca[$i - 1] === $cb[$j - 1]) ? 0 : 1;
            $dp[$j] = min($dp[$j] + 1, $dp[$j - 1] + 1, $prev + $cost);
            $prev = $tmp;
        }
    }

    return $dp[$n];
}

function fate_store_search_edit_similarity(string $a, string $b): float
{
    $la = fate_store_search_strlen($a);
    $lb = fate_store_search_strlen($b);
    if ($la === 0 && $lb === 0) {
        return 1.0;
    }
    if ($la === 0 || $lb === 0) {
        return 0.0;
    }
    if ($a === $b) {
        return 1.0;
    }

    $maxLen = max($la, $lb);
    if ($maxLen > 48) {
        return fate_store_search_bigram_similarity($a, $b);
    }

    $dist = fate_store_search_unicode_levenshtein($a, $b);

    return max(0.0, 1.0 - ($dist / $maxLen));
}

function fate_store_search_term_similarity(string $term, string $haystack): float
{
    $term = trim($term);
    $haystack = trim($haystack);
    if ($term === '' || $haystack === '') {
        return 0.0;
    }

    $pos = function_exists('mb_stripos') ? mb_stripos($haystack, $term) : stripos($haystack, $term);
    if ($pos !== false) {
        return 1.0;
    }

    $bigram = fate_store_search_bigram_similarity($term, $haystack);
    $edit = fate_store_search_edit_similarity($term, $haystack);
    $best = max($bigram, $edit);

    $len = fate_store_search_strlen($term);
    $hl = fate_store_search_strlen($haystack);
    if ($len >= 2 && $hl >= $len) {
        for ($start = 0; $start <= $hl - $len; $start++) {
            $slice = mb_substr($haystack, $start, $len);
            $best = max(
                $best,
                fate_store_search_bigram_similarity($term, $slice),
                fate_store_search_edit_similarity($term, $slice)
            );
            if ($best >= 0.95) {
                break;
            }
        }

        foreach ([$len - 1, $len + 1] as $wlen) {
            if ($wlen < 2 || $wlen > $hl) {
                continue;
            }
            for ($start = 0; $start <= $hl - $wlen; $start++) {
                $slice = mb_substr($haystack, $start, $wlen);
                $best = max(
                    $best,
                    fate_store_search_bigram_similarity($term, $slice) * 0.96,
                    fate_store_search_edit_similarity($term, $slice) * 0.96
                );
            }
        }
    } elseif ($len > $hl && $hl >= 2) {
        $best = max($best, fate_store_search_bigram_similarity($term, $haystack), fate_store_search_edit_similarity($term, $haystack));
    }

    return $best;
}

/**
 * @param list<string> $tokens
 */
function fate_store_search_row_score(array $tokens, array $row): float
{
    $fields = [
        trim((string) ($row['store_name'] ?? '')),
        trim((string) ($row['display_address'] ?? '')),
        trim((string) ($row['district_name'] ?? '')),
        trim((string) ($row['city_name'] ?? '')),
        preg_replace('/\D/u', '', (string) ($row['phone'] ?? '')) ?? '',
    ];

    $scores = [];
    foreach ($tokens as $token) {
        $best = 0.0;
        foreach ($fields as $field) {
            if ($field === '') {
                continue;
            }
            $best = max($best, fate_store_search_term_similarity($token, $field));
        }
        $scores[] = $best;
    }

    if ($scores === []) {
        return 0.0;
    }

    return array_sum($scores) / count($scores);
}

/**
 * @param list<string> $tokens
 */
function fate_store_search_typo_threshold(array $tokens): float
{
    $maxLen = 0;
    foreach ($tokens as $token) {
        $maxLen = max($maxLen, fate_store_search_strlen($token));
    }

    if ($maxLen <= 3) {
        return 0.62;
    }
    if ($maxLen <= 5) {
        return 0.52;
    }

    return 0.45;
}

/**
 * 錯字容錯：以首字／前兩字縮小候選，再以相似度打分。
 *
 * @param list<string> $tokens
 * @param array<string,string|int> $params
 */
function fate_store_search_build_typo_anchor(array $tokens, array &$params): string
{
    $groups = [];
    foreach ($tokens as $i => $token) {
        $len = fate_store_search_strlen($token);
        if ($len === 0) {
            continue;
        }
        if ($len === 1) {
            $groups[] = fate_store_search_term_sql('sq_ty' . $i, $token, $params);
            continue;
        }

        $prefix = mb_substr($token, 0, min(2, $len));
        $first = mb_substr($token, 0, 1);
        if ($prefix === false || $prefix === '') {
            continue;
        }
        if ($first === false || $first === '') {
            $first = $prefix;
        }

        $sub = [];
        $colSuffix = [
            'store_name' => 'sn',
            'display_address' => 'sa',
            'district_name' => 'sd',
            'city_name' => 'sc',
        ];
        foreach ($colSuffix as $col => $suffix) {
            $pxKey = ':sq_ty' . $i . '_px_' . $suffix;
            $fcKey = ':sq_ty' . $i . '_fc_' . $suffix;
            $params[$pxKey] = '%' . fate_store_search_like_escape($prefix) . '%';
            $params[$fcKey] = '%' . fate_store_search_like_escape($first) . '%';
            $sub[] = '(' . $col . ' LIKE ' . $pxKey . ' OR ' . $col . ' LIKE ' . $fcKey . ')';
        }
        $groups[] = '(' . implode(' OR ', $sub) . ')';
    }

    return $groups !== [] ? '(' . implode(' OR ', $groups) . ')' : '0';
}

/**
 * @param list<string> $filterConds
 * @param array<string,string|int> $filterParams
 * @param list<string> $tokens
 * @return array{stores:list<array<string,mixed>>,total:int,page:int,totalPages:int}
 */
function fate_store_search_typo(
    PDO $pdo,
    array $filterConds,
    array $filterParams,
    array $tokens,
    int $page,
    int $perPage
): array {
    $page = max(1, $page);
    $perPage = max(1, min(100, $perPage));

    $anchorParams = [];
    $anchorSql = fate_store_search_build_typo_anchor($tokens, $anchorParams);
    [$where, $params] = fate_store_search_merge_where($filterConds, $filterParams, $anchorSql, $anchorParams);

    $sql = "SELECT spa_store_id, store_name, display_address, city_name, district_name,
                   place_rating, place_ratings_total, cover_photo_path, photos_json,
                   public_detail_url_path, synced_at, phone, payload_json
            FROM spa_store_snapshots " . $where . ' LIMIT 500';

    $candidates = [];
    $st = $pdo->prepare($sql);
    fate_store_search_pdo_execute($st, $sql, $params);
    while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
        if (is_array($row)) {
            $candidates[] = $row;
        }
    }

    $threshold = fate_store_search_typo_threshold($tokens);
    $scored = [];
    foreach ($candidates as $row) {
        $score = fate_store_search_row_score($tokens, $row);
        if ($score < $threshold) {
            continue;
        }
        $row['_search_score'] = $score;
        $scored[] = $row;
    }

    usort($scored, static function (array $a, array $b): int {
        $scoreCmp = ($b['_search_score'] ?? 0) <=> ($a['_search_score'] ?? 0);
        if ($scoreCmp !== 0) {
            return $scoreCmp;
        }

        $ra = isset($a['place_rating']) && $a['place_rating'] !== '' && $a['place_rating'] !== null
            ? (float) $a['place_rating']
            : -1.0;
        $rb = isset($b['place_rating']) && $b['place_rating'] !== '' && $b['place_rating'] !== null
            ? (float) $b['place_rating']
            : -1.0;
        if ($ra !== $rb) {
            return $rb <=> $ra;
        }

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

    $total = count($scored);
    $totalPages = max(1, (int) ceil(max(0, $total) / $perPage));
    $page = min($page, $totalPages);
    $offset = ($page - 1) * $perPage;
    $pageRows = array_slice($scored, $offset, $perPage);
    $stores = [];
    foreach ($pageRows as $row) {
        unset($row['_search_score']);
        $stores[] = $row;
    }

    return [
        'stores' => $stores,
        'total' => $total,
        'page' => $page,
        'totalPages' => $totalPages,
    ];
}

/**
 * 僅保留 SQL 內實際出現的命名參數（避免 HY093）。
 *
 * @param array<string,string|int> $params
 * @return array<string,string|int>
 */
function fate_store_search_params_for_sql(string $sql, array $params): array
{
    if (!preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $sql, $matches)) {
        return [];
    }

    $filtered = [];
    foreach (array_unique($matches[0]) as $key) {
        if (array_key_exists($key, $params)) {
            $filtered[$key] = $params[$key];
            continue;
        }
        $bare = ltrim($key, ':');
        if (array_key_exists($bare, $params)) {
            $filtered[$key] = $params[$bare];
        }
    }

    return $filtered;
}

/**
 * @param array<string,string|int> $params
 */
function fate_store_search_pdo_execute(PDOStatement $stmt, string $sql, array $params): void
{
    $stmt->execute(fate_store_search_params_for_sql($sql, $params));
}

/**
 * @param array<string,string|int> $filterParams
 * @return array{0:string,1:array<string,string|int>}
 */
function fate_store_search_merge_where(array $filterConds, array $filterParams, string $searchSql, array $searchParams): array
{
    $conds = $filterConds;
    if ($searchSql !== '') {
        $conds[] = $searchSql;
    }
    $params = array_merge($filterParams, $searchParams);
    $where = $conds !== [] ? ('WHERE ' . implode(' AND ', $conds)) : '';

    return [$where, $params];
}

/**
 * @param array<string,string|int> $params
 * @return array{stores:list<array<string,mixed>>,total:int,page:int,totalPages:int}
 */
function fate_store_search_execute(
    PDO $pdo,
    string $whereClause,
    array $params,
    string $sort,
    int $page,
    int $perPage
): array {
    $page = max(1, $page);
    $perPage = max(1, min(100, $perPage));

    $countSql = 'SELECT COUNT(*) FROM spa_store_snapshots ' . $whereClause;
    $cst = $pdo->prepare($countSql);
    fate_store_search_pdo_execute($cst, $countSql, $params);
    $total = (int) $cst->fetchColumn();

    $totalPages = max(1, (int) ceil(max(0, $total) / $perPage));
    $page = min($page, $totalPages);
    $offset = ($page - 1) * $perPage;

    $orderSql = match ($sort) {
        'name' => 'store_name ASC',
        'recent' => 'synced_at DESC, spa_store_id DESC',
        default => 'place_rating IS NULL, place_rating DESC, place_ratings_total DESC, store_name ASC',
    };

    $sql = "SELECT spa_store_id, store_name, display_address, city_name, district_name,
                   place_rating, place_ratings_total, cover_photo_path, photos_json,
                   public_detail_url_path, synced_at, phone, payload_json
            FROM spa_store_snapshots " . $whereClause . '
            ORDER BY ' . $orderSql . ' LIMIT ' . (int) $perPage . ' OFFSET ' . (int) $offset;

    $stores = [];
    $st = $pdo->prepare($sql);
    fate_store_search_pdo_execute($st, $sql, $params);
    while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
        if (is_array($row)) {
            $stores[] = $row;
        }
    }

    return [
        'stores' => $stores,
        'total' => $total,
        'page' => $page,
        'totalPages' => $totalPages,
    ];
}

/**
 * 店家列表搜尋：先精準，無結果再模糊。
 *
 * @param list<string> $filterConds 不含搜尋條件
 * @param array<string,string|int> $filterParams
 * @return array{
 *   stores:list<array<string,mixed>>,
 *   total:int,
 *   page:int,
 *   totalPages:int,
 *   mode:string
 * }
 */
function fate_store_search_list(
    PDO $pdo,
    array $filterConds,
    array $filterParams,
    string $q,
    string $sort,
    int $page,
    int $perPage
): array {
    $q = trim($q);
    if ($q === '') {
        [$where, $params] = fate_store_search_merge_where($filterConds, $filterParams, '', []);
        $result = fate_store_search_execute($pdo, $where, $params, $sort, $page, $perPage);

        return array_merge($result, ['mode' => FATE_STORE_SEARCH_MODE_NONE]);
    }

    $tokens = fate_store_search_tokens($q);

    $preciseParams = [];
    $preciseSql = fate_store_search_build_precise($tokens, $q, $preciseParams);
    [$where, $params] = fate_store_search_merge_where($filterConds, $filterParams, $preciseSql, $preciseParams);
    $precise = fate_store_search_execute($pdo, $where, $params, $sort, $page, $perPage);
    if ($precise['total'] > 0) {
        return array_merge($precise, ['mode' => FATE_STORE_SEARCH_MODE_PRECISE]);
    }

    $fuzzyParams = [];
    $fuzzySql = fate_store_search_build_fuzzy($tokens, $q, $fuzzyParams);
    [$where, $params] = fate_store_search_merge_where($filterConds, $filterParams, $fuzzySql, $fuzzyParams);
    $fuzzy = fate_store_search_execute($pdo, $where, $params, $sort, 1, $perPage);
    if ($fuzzy['total'] > 0) {
        return array_merge($fuzzy, ['mode' => FATE_STORE_SEARCH_MODE_FUZZY]);
    }

    $typo = fate_store_search_typo($pdo, $filterConds, $filterParams, $tokens, $page, $perPage);

    return array_merge($typo, ['mode' => $typo['total'] > 0 ? FATE_STORE_SEARCH_MODE_FUZZY : FATE_STORE_SEARCH_MODE_NONE]);
}
