<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/error_handler.php';

function initGetID3() {
    if (class_exists('getID3')) {
        return true;
    }
    $composerAutoload = dirname(__DIR__) . '/vendor/autoload.php';
    if (file_exists($composerAutoload)) {
        try {
            require_once $composerAutoload;
            if (class_exists('getID3')) return true;
        } catch (Throwable $e) {}
    }
    $localPaths = [
        __DIR__ . '/getid3/getid3.php',
        dirname(__DIR__) . '/libs/getid3/getid3.php',
        __DIR__ . '/getid3.class.php'
    ];
    foreach ($localPaths as $path) {
        if (file_exists($path)) {
            try {
                require_once $path;
                if (class_exists('getID3')) return true;
            } catch (Throwable $e) {}
        }
    }
    return false;
}

function getEmbeddedCover($filePath) {
    $resolvedFilePath = resolveFilesystemPath($filePath, 'readable_file');
    if ($resolvedFilePath === null) return null;

    if (initGetID3()) {
        try {
            $getID3 = new getID3;
            $getID3->option_md5_data = false;
            $getID3->option_md5_data_source = false;
            $getID3->encoding = 'UTF-8';
            $fileInfo = $getID3->analyze($resolvedFilePath);
            if (isset($fileInfo['comments']['picture'][0])) {
                return [
                    'data' => $fileInfo['comments']['picture'][0]['data'],
                    'mime' => $fileInfo['comments']['picture'][0]['image_mime'] ?? 'image/jpeg',
                ];
            }
        } catch (Throwable $e) {
        }
    }

    $fp = fopen($resolvedFilePath, 'rb');
    if (!$fp) return null;
    $header = fread($fp, 10);
    if (strlen($header) < 10 || substr($header, 0, 3) !== 'ID3') {
        fclose($fp);
        return null;
    }

    $version = ord($header[3]);
    $sizeBytes = unpack('C4', substr($header, 6, 4));
    $tagSize = (($sizeBytes[1] & 0x7F) << 21) | (($sizeBytes[2] & 0x7F) << 14) | (($sizeBytes[3] & 0x7F) << 7) | ($sizeBytes[4] & 0x7F);
    if ($tagSize <= 0) {
        fclose($fp);
        return null;
    }

    $tagData = fread($fp, min($tagSize, 1024 * 1024 * 5));
    fclose($fp);

    $offset = 0;
    $len = strlen($tagData);
    $targetFrame = ($version === 2) ? 'PIC' : 'APIC';
    $frameHeaderLen = ($version === 2) ? 6 : 10;

    while ($offset < $len - $frameHeaderLen) {
        if ($version === 2) {
            $frameId = substr($tagData, $offset, 3);
            $frameSize = (ord($tagData[$offset + 3]) << 16) | (ord($tagData[$offset + 4]) << 8) | ord($tagData[$offset + 5]);
            $bodyOffset = 6;
        } else {
            $frameId = substr($tagData, $offset, 4);
            $frameSizeUnpack = @unpack('N', substr($tagData, $offset + 4, 4));
            $frameSize = $frameSizeUnpack ? $frameSizeUnpack[1] : 0;
            if ($version === 4) {
                $b1 = ($frameSize >> 24) & 0xFF;
                $b2 = ($frameSize >> 16) & 0xFF;
                $b3 = ($frameSize >> 8) & 0xFF;
                $b4 = $frameSize & 0xFF;
                $frameSize = (($b1 & 0x7F) << 21) | (($b2 & 0x7F) << 14) | (($b3 & 0x7F) << 7) | ($b4 & 0x7F);
            }
            $bodyOffset = 10;
        }

        if ($frameSize <= 0 || $frameSize > ($len - $offset)) break;
        if ($frameId === $targetFrame) {
            $body = substr($tagData, $offset + $bodyOffset, $frameSize);
            if ($version === 2 && strlen($body) > 6) {
                $format = strtolower(substr($body, 1, 3));
                $mime = $format === 'png' ? 'image/png' : 'image/jpeg';
                $descOffset = 5;
                while ($descOffset < strlen($body) && ord($body[$descOffset]) !== 0) $descOffset++;
                return ['data' => substr($body, $descOffset + 1), 'mime' => $mime];
            }
            if ($version !== 2 && strlen($body) > 4) {
                $encoding = ord($body[0]);
                $mimeOffset = 1;
                while ($mimeOffset < strlen($body) && ord($body[$mimeOffset]) !== 0) $mimeOffset++;
                $mime = substr($body, 1, $mimeOffset - 1) ?: 'image/jpeg';
                $descOffset = $mimeOffset + 2;
                if ($encoding === 1 || $encoding === 2) {
                    while ($descOffset < strlen($body) - 1 && !(ord($body[$descOffset]) === 0 && ord($body[$descOffset + 1]) === 0)) $descOffset += 2;
                    $descOffset += 2;
                } else {
                    while ($descOffset < strlen($body) && ord($body[$descOffset]) !== 0) $descOffset++;
                    $descOffset++;
                }
                return ['data' => substr($body, $descOffset), 'mime' => $mime];
            }
        }
        $offset += $frameHeaderLen + $frameSize;
    }

    return null;
}

/**
 * 단일 문자열이 정상 UTF-8인지 빠르게 확인한다.
 * mb_check_encoding은 incomplete sequence 등을 엄격하게 잡는다.
 */
function isValidUtf8($str) {
    if (!function_exists('mb_check_encoding')) {
        return preg_match('//u', $str) === 1;
    }
    return mb_check_encoding($str, 'UTF-8');
}

/**
 * 문자열에 UTF-8 한글 범위 코드포인트가 포함되어 있는지 확인한다.
 * UTF-8 인코딩 기준으로: 한글 가나다 영역은 U+AC00~U+D7A3 (UTF-8: EA B0 80 ~ ED 9E A3)
 * ASCII만 남거나 깨진 변환 결과를 걸러낼 때 사용한다.
 */
function hasKoreanChars($str) {
    // 이진 안전하게 처리: 매바이트 레벨에서 한글 UTF-8 시감 판단 (\xEA~\xED 로 시작하는 3바이트)
    $len = strlen($str);
    for ($i = 0; $i < $len - 2; $i++) {
        $b0 = ord($str[$i]);
        if ($b0 >= 0xEA && $b0 <= 0xED) {
            $b1 = ord($str[$i + 1]);
            $b2 = ord($str[$i + 2]);
            if ($b1 >= 0x80 && $b1 <= 0xBF && $b2 >= 0x80 && $b2 <= 0xBF) {
                return true;
            }
        }
    }
    return false;
}

/**
 * 강화된 UTF-8 정규화 함수.
 * 1. null / 빈값 처리
 * 2. HTML 엔티티 디코드 (getID3 option_tags_html 잔재 제거)
 * 3. 이미 유효한 UTF-8이면 제어문자 제거 후 반환
 * 4. UTF-8이 아닌 경우 CP949 → EUC-KR → UTF-16LE → UTF-16BE → ISO-8859-1 순 시도
 * 5. 변환 후 유효성 확인
 */
function fixEncodingIfNeeded($value) {
    if ($value === null || $value === false) return null;
    if (is_array($value)) {
        // 배열이면 첫 번째 유효 원소를 찾아 반환
        foreach ($value as $item) {
            if (is_array($item)) $item = firstNonEmptyValue($item);
            $result = fixEncodingIfNeeded($item);
            if ($result !== null && $result !== '') return $result;
        }
        return null;
    }
    $value = (string)$value;

    // HTML 엔티티 정리 (getID3 tags_html 잔재 또는 중복 encode 방지)
    if (strpos($value, '&') !== false && (strpos($value, ';') !== false || strpos($value, '&amp;') !== false)) {
        $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        if ($decoded !== $value) $value = $decoded;
    }

    $value = trim($value);
    if ($value === '') return null;

    // 1단계: 이미 유효한 UTF-8이면 제어문자만 제거하고 반환
    // (제어문자 제거에 /u 플래그를 씁 수 있음: UTF-8 유효성 확인 후 적용)
    if (isValidUtf8($value)) {
        // \x00-\x08, \x0B, \x0C, \x0E-\x1F, \x7F 제거
        $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
        if ($clean === null) $clean = $value; // preg_replace 실패 시 원본 유지
        $clean = trim($clean);
        return $clean === '' ? null : $clean;
    }

    // 2단계: UTF-8이 아닌 경우 인코딩 변환 시도
    if (!function_exists('mb_convert_encoding')) {
        return $value; // mb 없으면 원본 반환
    }

    $candidates = ['CP949', 'EUC-KR', 'UTF-16LE', 'UTF-16BE', 'ISO-8859-1'];
    $originalValue = $value;
    foreach ($candidates as $fromEnc) {
        try {
            $converted = @mb_convert_encoding($value, 'UTF-8', $fromEnc);
            if ($converted === false || $converted === '') continue;
            // 변환 후 제어문자 제거 (지금은 UTF-8이므로 /u 안전)
            if (isValidUtf8($converted)) {
                $converted = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $converted);
                if ($converted === null) $converted = '';
                $converted = trim($converted);
                if ($converted === '') continue;
                // ISO-8859-1 변환 결과가 한글이 전혀 없고 원본보다 너무 짧으면 의심
                if ($fromEnc === 'ISO-8859-1' && !hasKoreanChars($converted) && strlen($converted) < strlen($originalValue) / 2) {
                    continue;
                }
                return $converted;
            }
        } catch (Throwable $e) {
            continue;
        }
    }

    // 모든 변환 실패 시 원본 반환 (빈 값보다 낫다)
    return $originalValue;
}

/**
 * getID3 태그 배열에서 추출한 값을 DB 저장 전에 정규화하는 공통 함수.
 * - 배열이면 첫 번째 유효 문자열 추출
 * - fixEncodingIfNeeded 적용
 * - 빈 값이면 null 반환
 */
function normalizeMetadataString($value) {
    if ($value === null || $value === false) return null;
    if (is_array($value)) {
        $result = fixEncodingIfNeeded($value);
        return ($result !== null && $result !== '') ? $result : null;
    }
    $result = fixEncodingIfNeeded((string)$value);
    return ($result !== null && $result !== '') ? $result : null;
}

function normalizeMetadataValue($value) {
    if (is_array($value)) $value = fixEncodingIfNeeded($value);
    else $value = fixEncodingIfNeeded($value);
    return ($value === null || $value === '') ? null : $value;
}

function firstNonEmptyValue($values) {
    if (!is_array($values)) return normalizeMetadataValue($values);
    foreach ($values as $value) {
        if (is_array($value)) $item = firstNonEmptyValue($value);
        else $item = fixEncodingIfNeeded($value);
        if ($item !== null && $item !== '') return $item;
    }
    return null;
}

function firstTagValue($tags, $keys) {
    foreach ($keys as $key) {
        if (array_key_exists($key, $tags)) {
            $value = normalizeMetadataString($tags[$key]);
            if ($value !== null) return $value;
        }
    }
    return null;
}

function normalizeTrackNumber($value) {
    $value = normalizeMetadataString($value);
    if ($value === null) return null;
    if (preg_match('/\d+/', $value, $m)) return (int)$m[0];
    return null;
}

function parseTrackNumber($value) {
    return normalizeTrackNumber($value);
}

function normalizeYear($value) {
    $value = normalizeMetadataString($value);
    if ($value === null) return null;
    if (preg_match('/(19|20)\d{2}/', $value, $m)) return $m[0];
    if (preg_match('/^\d{4}/', $value, $m)) return $m[0];
    return substr($value, 0, 10);
}

function normalizeGenre($value) {
    if (is_array($value)) {
        $items = [];
        foreach ($value as $item) {
            $item = normalizeMetadataString($item);
            if ($item !== null) $items[] = $item;
        }
        return empty($items) ? null : implode(', ', array_values(array_unique($items)));
    }
    return normalizeMetadataString($value);
}

function parseFilenameMetadata($filePath) {
    $filename = pathinfo($filePath, PATHINFO_FILENAME);
    $filename = normalizeMetadataValue($filename) ?: pathinfo($filePath, PATHINFO_FILENAME);
    $trackNo = null;
    $name = trim($filename);
    if (preg_match('/^\s*(\d{1,3})\s*[\.\-_]\s*/u', $name, $m)) {
        $trackNo = (int)$m[1];
        $name = preg_replace('/^\s*\d{1,3}\s*[\.\-_]\s*/u', '', $name);
    } elseif (preg_match('/^\s*(\d{1,3})\s+-\s+/u', $name, $m)) {
        $trackNo = (int)$m[1];
        $name = preg_replace('/^\s*\d{1,3}\s+-\s+/u', '', $name);
    }

    $albumFromBracket = null;
    if (preg_match('/^\s*\[([^\]]+)\]\s*(.+)$/u', $name, $m)) {
        $albumFromBracket = trim($m[1]);
        $name = trim($m[2]);
    }

    if (strpos($name, ' - ') === false && strpos($name, '_') !== false) {
        $underscoreParts = array_values(array_filter(array_map('trim', explode('_', $name)), function ($part) {
            return $part !== '';
        }));
        if (count($underscoreParts) === 2) {
            return [
                'artist' => $underscoreParts[0],
                'album' => $albumFromBracket,
                'title' => $underscoreParts[1],
                'track_no' => $trackNo,
                'metadata_source' => 'filename',
            ];
        }
    }

    $parts = array_values(array_filter(array_map('trim', explode(' - ', $name)), function ($part) {
        return $part !== '';
    }));
    $fallback = ['title' => trim($name ?: $filename), 'artist' => null, 'album' => $albumFromBracket, 'track_no' => $trackNo, 'metadata_source' => 'filename'];
    if (count($parts) >= 3) {
        return ['artist' => $parts[0], 'album' => $albumFromBracket ?: $parts[1], 'title' => implode(' - ', array_slice($parts, 2)), 'track_no' => $trackNo, 'metadata_source' => 'filename'];
    }
    if (count($parts) === 2) {
        return ['artist' => $parts[0], 'album' => $albumFromBracket, 'title' => $parts[1], 'track_no' => $trackNo, 'metadata_source' => 'filename'];
    }
    return $fallback;
}

function extractMusicMetadata($filePath) {
    $metadata = [
        'title' => null,
        'artist' => null,
        'album' => null,
        'album_artist' => null,
        'genre' => null,
        'year' => null,
        'track_no' => null,
        'disc_no' => null,
        'duration' => null,
        'composer' => null,
        'comment' => null,
        'bitrate' => null,
        'sample_rate' => null,
        'channels' => null,
        'cover_hash' => null,
        'cover_mime' => null,
        'has_cover' => 0,
        'metadata_source' => 'unknown',
        'metadata_error' => null,
        'filename_fallback_used' => 0,
        'tag_used' => 0,
    ];

    $tagRead = false;
    $useGetId3 = settingBool('scan_use_getid3', true);

    if ($useGetId3 && initGetID3()) {
        try {
            $getID3 = new getID3;
            $getID3->option_md5_data = false;
            $getID3->option_md5_data_source = false;
            $getID3->encoding = 'UTF-8';           // 출력 인코딩 기준
            $getID3->option_tags_html = false;     // HTML 엔티티 변환 비활성화 — tags 배열에 원문 보존
            $getID3->encoding_id3v1 = 'CP949';     // 한국 오래된 MP3 ID3v1 인코딩 (Windows 한국어 표준)
            $metadataPath = resolveFilesystemPath($filePath, 'readable_file') ?? toWinPath($filePath);
            $fileInfo = $getID3->analyze($metadataPath);
            if (isset($fileInfo['error']) && !empty($fileInfo['error'])) {
                $errStr = is_array($fileInfo['error']) ? implode('; ', $fileInfo['error']) : (string)$fileInfo['error'];
                throw new Exception('getID3 analyze error: ' . $errStr);
            }
            // id3v2 태그를 최우선으로, id3v1은 fallback으로 사용
            $tags = [];
            foreach (['id3v2', 'quicktime', 'vorbiscomment', 'riff', 'ape', 'asf', 'id3v1'] as $tagKey) {
                if (isset($fileInfo['tags'][$tagKey]) && is_array($fileInfo['tags'][$tagKey])) {
                    // id3v1은 이미 있는 값을 덮어쓰지 않음 (id3v2가 우선)
                    foreach ($fileInfo['tags'][$tagKey] as $k => $v) {
                        if (!isset($tags[$k]) || (is_array($tags[$k]) && empty(array_filter($tags[$k], fn($x) => $x !== null && $x !== ''))) ) {
                            $tags[$k] = $v;
                        }
                    }
                    $tagRead = true;
                }
            }

            $metadata['title']        = firstTagValue($tags, ['title']);
            $metadata['artist']       = firstTagValue($tags, ['artist', 'artists']);
            $metadata['album']        = firstTagValue($tags, ['album']);
            $metadata['album_artist'] = firstTagValue($tags, ['album_artist', 'albumartist', 'album artist', 'band', 'ensemble']);
            $metadata['genre']        = normalizeGenre($tags['genre'] ?? null);
            $metadata['year']         = normalizeYear(firstTagValue($tags, ['year', 'date', 'creation_date', 'originalyear']));
            $metadata['track_no']     = normalizeTrackNumber(firstTagValue($tags, ['track_number', 'track', 'tracknumber']));
            $metadata['disc_no']      = normalizeTrackNumber(firstTagValue($tags, ['part_of_a_set', 'discnumber', 'disc_number']));
            $metadata['composer']     = firstTagValue($tags, ['composer']);
            $metadata['comment']      = firstTagValue($tags, ['comment', 'description']);
            $metadata['duration']     = isset($fileInfo['playtime_seconds']) ? (int)round($fileInfo['playtime_seconds']) : null;
            $metadata['bitrate']      = isset($fileInfo['audio']['bitrate']) ? (int)round($fileInfo['audio']['bitrate']) : null;
            $metadata['sample_rate']  = isset($fileInfo['audio']['sample_rate']) ? (int)$fileInfo['audio']['sample_rate'] : null;
            $metadata['channels']     = isset($fileInfo['audio']['channels']) ? normalizeMetadataString((string)$fileInfo['audio']['channels']) : null;
            $hasAnyTagValue = false;
            foreach (['title', 'artist', 'album', 'album_artist', 'genre', 'year', 'track_no', 'disc_no', 'composer', 'comment'] as $key) {
                if ($metadata[$key] !== null && $metadata[$key] !== '') {
                    $hasAnyTagValue = true;
                    break;
                }
            }
            if ($tagRead && $hasAnyTagValue) {
                $metadata['metadata_source'] = 'internal';
                $metadata['tag_used'] = 1;
            }
        } catch (Throwable $e) {
            $metadata['metadata_error'] = limitLogText($e->getMessage(), 500);
        }
    } else {
        $metadata['metadata_error'] = !$useGetId3 ? 'getID3 disabled by admin settings' : 'getID3 library not available';
    }

    try {
        $cover = getEmbeddedCover($filePath);
        if ($cover && !empty($cover['data'])) {
            $metadata['cover_hash'] = md5($cover['data']);
            $metadata['cover_mime'] = normalizeMetadataValue($cover['mime'] ?? 'image/jpeg');
            $metadata['has_cover'] = 1;
        }
    } catch (Throwable $e) {
        $metadata['metadata_error'] = trim(($metadata['metadata_error'] ? $metadata['metadata_error'] . '; ' : '') . limitLogText($e->getMessage(), 500));
    }

    $useFallback = settingBool('scan_use_fallback_on_failure', true);
    $filenameMeta = [];
    if ($useFallback) {
        $filenameMeta = parseFilenameMetadata($filePath);
    }
    foreach (['title', 'artist', 'album'] as $key) {
        if (trim((string)($metadata[$key] ?? '')) === '' && !empty($filenameMeta[$key])) {
            $metadata[$key] = $filenameMeta[$key];
            $metadata['filename_fallback_used'] = 1;
        }
    }
    if ($metadata['track_no'] === null && !empty($filenameMeta['track_no'])) {
        $metadata['track_no'] = (int)$filenameMeta['track_no'];
        $metadata['filename_fallback_used'] = 1;
    }
    if (trim((string)$metadata['title']) === '') {
        $metadata['title'] = pathinfo($filePath, PATHINFO_FILENAME);
        $metadata['filename_fallback_used'] = 1;
    }
    if ($metadata['tag_used'] && $metadata['filename_fallback_used']) {
        $metadata['metadata_source'] = 'mixed';
    } elseif (!$metadata['tag_used'] && $metadata['filename_fallback_used']) {
        $metadata['metadata_source'] = 'filename';
    } elseif (!$metadata['tag_used'] && !$metadata['filename_fallback_used']) {
        $metadata['metadata_source'] = 'unknown';
    }

    return $metadata;
}

function writeScanLog($message, $level = 'INFO') {
    if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0777, true);
    $message = maskInternalPathText($message);
    file_put_contents(SCAN_LOG_FILE, sprintf("[%s] [%s] %s%s", date('Y-m-d H:i:s'), $level, $message, PHP_EOL), FILE_APPEND);
}

function buildKoreanScanSummary(array $stats) {
    $total = (int)($stats['total_found'] ?? $stats['total'] ?? 0);
    $new = (int)($stats['new_count'] ?? $stats['new'] ?? 0);
    $updated = (int)($stats['updated_count'] ?? $stats['updated'] ?? 0);
    $skipped = (int)($stats['skipped_count'] ?? $stats['skipped'] ?? 0);
    $missing = (int)($stats['missing_count'] ?? $stats['deleted_count'] ?? $stats['missing'] ?? $stats['deleted'] ?? 0);
    $tag = (int)($stats['tag_count'] ?? $stats['tag'] ?? 0);
    $filename = (int)($stats['filename_count'] ?? $stats['filename'] ?? 0);
    $manual = (int)($stats['manual_protected_count'] ?? $stats['manual_protected'] ?? 0);
    $cover = (int)($stats['cover_success_count'] ?? $stats['cover'] ?? 0);
    $noCover = (int)($stats['cover_missing_count'] ?? $stats['no_cover'] ?? 0);
    $metadataErrors = (int)($stats['metadata_error_count'] ?? $stats['metadata_errors'] ?? 0);
    $encodingFixed = (int)($stats['encoding_fixed'] ?? 0);
    $fileErrors = (int)($stats['file_errors'] ?? $stats['errors'] ?? 0);
    $elapsed = $stats['elapsed'] ?? null;

    $parts = [
        '스캔 완료',
        '발견 ' . number_format($total) . '곡',
        '신규 ' . number_format($new) . '곡',
        '갱신 ' . number_format($updated) . '곡',
        '건너뜀 ' . number_format($skipped) . '곡',
        '파일 없음 ' . number_format($missing) . '곡',
        '태그 분석 ' . number_format($tag) . '곡',
        '파일명 분석 ' . number_format($filename) . '곡',
        '수동 보호 ' . number_format($manual) . '곡',
        '앨범아트 있음 ' . number_format($cover) . '곡',
        '앨범아트 없음 ' . number_format($noCover) . '곡',
        '메타데이터 오류 ' . number_format($metadataErrors) . '건',
        '인코딩 보정 ' . number_format($encodingFixed) . '건',
        '파일 처리 오류 ' . number_format($fileErrors) . '건',
    ];
    if ($elapsed !== null && $elapsed !== '') {
        $parts[] = '소요 ' . rtrim(rtrim((string)$elapsed, '0'), '.') . '초';
    }
    return implode(', ', $parts);
}

function scanMessageToKorean($message) {
    $message = trim((string)$message);
    if ($message === '') return '';
    $normalizedMessage = preg_replace('/[A-Z]:[\\\\\\/][^\r\n]*/i', '[경로 숨김]', $message);
    if (preg_match('/Scan completed\.\s*Found:\s*(\d+),\s*new:\s*(\d+),\s*updated:\s*(\d+),\s*skipped:\s*(\d+),\s*missing:\s*(\d+),\s*tags:\s*(\d+),\s*filename:\s*(\d+),\s*manual protected:\s*(\d+),\s*cover:\s*(\d+),\s*no cover:\s*(\d+),\s*metadata errors:\s*(\d+),\s*encoding fixed:\s*(\d+),\s*file errors:\s*(\d+),\s*elapsed:\s*([0-9.]+)s/i', $normalizedMessage, $m)) {
        return buildKoreanScanSummary([
            'total_found' => $m[1],
            'new_count' => $m[2],
            'updated_count' => $m[3],
            'skipped_count' => $m[4],
            'missing_count' => $m[5],
            'tag_count' => $m[6],
            'filename_count' => $m[7],
            'manual_protected_count' => $m[8],
            'cover_success_count' => $m[9],
            'cover_missing_count' => $m[10],
            'metadata_error_count' => $m[11],
            'encoding_fixed' => $m[12],
            'file_errors' => $m[13],
            'elapsed' => $m[14],
        ]);
    }
    if (stripos($normalizedMessage, 'Scan failed:') !== false) {
        return preg_replace('/Scan failed:/i', '스캔 실패:', $normalizedMessage, 1);
    }
    if (stripos($normalizedMessage, 'Music scan started.') !== false) {
        return '음악 스캔을 시작했습니다.';
    }
    if (stripos($normalizedMessage, 'File processing error:') !== false) {
        return preg_replace('/File processing error:/i', '파일 처리 오류:', $normalizedMessage, 1);
    }
    if (stripos($normalizedMessage, 'getID3 library not available') !== false) {
        return str_ireplace('getID3 library not available', 'getID3 라이브러리를 사용할 수 없어 파일명 분석으로 처리했습니다', $normalizedMessage);
    }
    return $normalizedMessage;
}

function formatDuration($seconds) {
    if ($seconds === null || $seconds <= 0) return '--:--';
    return sprintf('%02d:%02d', floor($seconds / 60), $seconds % 60);
}

function isWindowsRuntime() {
    return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
}

function pathConversionLooksLossy($original, $converted) {
    $original = (string)$original;
    $converted = (string)$converted;
    return $original !== ''
        && $converted !== $original
        && strpos($original, '?') === false
        && strpos($converted, '?') !== false;
}

function filesystemPathCandidates($path) {
    $path = (string)$path;
    if ($path === '') return [];

    $candidates = [$path];
    if (isWindowsRuntime() && function_exists('mb_convert_encoding')) {
        $converted = @mb_convert_encoding($path, 'CP949', 'UTF-8');
        if (is_string($converted) && $converted !== '' && !pathConversionLooksLossy($path, $converted)) {
            $candidates[] = $converted;
        }
    }

    if (isWindowsRuntime() && function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) {
        $detected = @mb_detect_encoding($path, ['UTF-8', 'CP949', 'EUC-KR'], true);
        if ($detected && $detected !== 'UTF-8') {
            $utf8 = @mb_convert_encoding($path, 'UTF-8', $detected);
            if (is_string($utf8) && $utf8 !== '' && !pathConversionLooksLossy($path, $utf8)) {
                $candidates[] = $utf8;
            }
        }
    }

    return array_values(array_unique($candidates));
}

function filesystemPathMatchesType($path, $type = 'any') {
    if (!is_string($path) || $path === '') return false;
    switch ($type) {
        case 'file':
            return @is_file($path);
        case 'dir':
            return @is_dir($path);
        case 'readable_file':
            return @is_file($path) && @is_readable($path);
        case 'readable_dir':
            return @is_dir($path) && @is_readable($path);
        case 'readable':
            return @file_exists($path) && @is_readable($path);
        case 'any':
        default:
            return @file_exists($path);
    }
}

function resolveFilesystemPath($path, $type = 'any') {
    foreach (filesystemPathCandidates($path) as $candidate) {
        if (filesystemPathMatchesType($candidate, $type)) {
            return $candidate;
        }
    }
    return null;
}

function filesystemPathExists($path, $type = 'any') {
    return resolveFilesystemPath($path, $type) !== null;
}

function toWinPath($path) {
    $path = (string)$path;
    if ($path === '' || !isWindowsRuntime() || !function_exists('mb_convert_encoding')) {
        return $path;
    }

    $resolved = resolveFilesystemPath($path, 'any');
    if ($resolved !== null) {
        return $resolved;
    }

    $converted = @mb_convert_encoding($path, 'CP949', 'UTF-8');
    if (is_string($converted) && $converted !== '' && !pathConversionLooksLossy($path, $converted)) {
        return $converted;
    }
    return $path;
}

function toUtf8Path($path) {
    $path = (string)$path;
    if (isWindowsRuntime() && function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) {
        $detect = @mb_detect_encoding($path, ['UTF-8', 'CP949', 'EUC-KR'], true);
        if ($detect && $detect !== 'UTF-8') {
            $converted = @mb_convert_encoding($path, 'UTF-8', $detect);
            if (is_string($converted) && $converted !== '') return $converted;
        }
    }
    return $path;
}

function ensureUtf8String($value) {
    if ($value === null) return '';
    if (is_array($value)) $value = firstNonEmptyValue($value);
    $value = (string)($value ?? '');
    if ($value === '') return '';
    if (function_exists('mb_check_encoding') && function_exists('mb_convert_encoding') && !mb_check_encoding($value, 'UTF-8')) {
        $converted = @mb_convert_encoding($value, 'UTF-8', ['UTF-8', 'CP949', 'EUC-KR', 'ISO-8859-1']);
        if (is_string($converted)) return $converted;
    }
    return $value;
}

function h($value) {
    return htmlspecialchars(ensureUtf8String($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function maskInternalPathText($value) {
    $value = ensureUtf8String($value);
    if ($value === '') return '';
    $value = preg_replace('/[A-Z]:[\\\\\/][^\r\n"\'<>|]+/iu', '[path]', $value);
    $value = preg_replace('/(?:\/|\\\\)(?:Users|laragon|xampp|var|home|srv|www)(?:[\\\\\/][^\r\n"\'<>|]+)+/iu', '[path]', $value);
    $value = preg_replace('/(?:file_path|folder_path|cover_cache_path)\s*[:=]\s*[^,\r\n}]+/iu', '$1=[path]', $value);
    return $value;
}

function limitLogText($value, $limit = 2000) {
    $value = maskInternalPathText($value);
    if ($limit <= 0) return $value;
    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
        return mb_strlen($value, 'UTF-8') > $limit ? mb_substr($value, 0, $limit, 'UTF-8') . '...' : $value;
    }
    return strlen($value) > $limit ? substr($value, 0, $limit) . '...' : $value;
}

function coverVersionForSong($song) {
    if (!is_array($song)) return '';
    $hash = trim((string)($song['cover_hash'] ?? ''));
    if ($hash !== '') return preg_replace('/[^a-zA-Z0-9_-]/', '', $hash);
    $updated = trim((string)($song['metadata_updated_at'] ?? $song['updated_at'] ?? ''));
    if ($updated !== '') return substr(md5($updated), 0, 12);
    return '';
}

function coverGlobalRevision() {
    static $revision = null;
    if ($revision !== null) return $revision;
    $revision = '';
    try {
        $pdo = getDBConnection();
        $value = $pdo->query("SELECT MAX(scan_finished_at) FROM scan_logs")->fetchColumn();
        if ($value) $revision = substr(md5((string)$value), 0, 12);
    } catch (Throwable $e) {
        $revision = '';
    }
    return $revision;
}

function coverUrl($songOrId, $basePath = '') {
    $id = is_array($songOrId) ? (int)($songOrId['id'] ?? 0) : (int)$songOrId;
    if ($id <= 0) return $basePath . 'assets/img/default-cover.svg';
    $url = $basePath . 'cover.php?id=' . rawurlencode((string)$id);
    $version = is_array($songOrId) ? coverVersionForSong($songOrId) : '';
    if ($version === '') $version = coverGlobalRevision();
    if ($version !== '') $url .= '&v=' . rawurlencode($version);
    return $url;
}

function sendSecurityHeaders($sensitive = false) {
    if (headers_sent()) return;
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: SAMEORIGIN');
    header('Referrer-Policy: strict-origin-when-cross-origin');
    header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
    if ($sensitive) {
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
    } else {
        header('Cache-Control: no-cache, must-revalidate, max-age=0');
    }
}

function jsonResponse($payload, $statusCode = 200) {
    if (is_array($payload) && function_exists('apiNormalizePayload')) {
        $payload = apiNormalizePayload($payload, (int)$statusCode);
    } elseif (is_array($payload)) {
        $success = array_key_exists('success', $payload) ? (bool)$payload['success'] : ((int)$statusCode < 400);
        $payload['success'] = $success;
        if ($success) {
            if (!array_key_exists('data', $payload)) $payload['data'] = new stdClass();
            if (!array_key_exists('message', $payload)) $payload['message'] = '';
        } else {
            if (!array_key_exists('error_code', $payload)) {
                $status = (int)$statusCode;
                $payload['error_code'] = $payload['code'] ?? (
                    $status === 401 ? 'UNAUTHORIZED' : (
                        $status === 403 ? 'FORBIDDEN' : (
                            $status === 404 ? 'NOT_FOUND' : (
                                $status === 405 ? 'INVALID_REQUEST' : (
                                    $status >= 500 ? 'SERVER_ERROR' : 'INVALID_REQUEST'
                                )
                            )
                        )
                    )
                );
            }
            if (!array_key_exists('data', $payload)) $payload['data'] = new stdClass();
            if (!array_key_exists('message', $payload) || trim((string)$payload['message']) === '') $payload['message'] = '요청을 처리할 수 없습니다.';
            if (isset($payload['message']) && is_string($payload['message'])) {
                $payload['message'] = function_exists('maskInternalPathText') ? maskInternalPathText($payload['message']) : $payload['message'];
                if (preg_match('/SQLSTATE|PDOException|Stack trace|Fatal error|Warning:|Notice:|Parse error|[A-Z]:[\\\\\/]/i', $payload['message'])) {
                    $payload['message'] = ((int)$statusCode >= 500) ? '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.' : '요청을 처리할 수 없습니다.';
                }
            }
        }
    }
    if (!headers_sent()) {
        while (ob_get_level() > 0 && ob_get_length() !== false && ob_get_length() > 0) {
            @ob_clean();
            break;
        }
    }
    if (!headers_sent()) {
        http_response_code($statusCode);
        sendSecurityHeaders(true);
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
    }
    $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
    if ($json === false) {
        if (function_exists('writeAppLog')) {
            writeAppLog('error', 'json_encode_error', 'JSON response encode failed', ['error' => json_last_error_msg()]);
        }
        http_response_code(500);
        $json = '{"success":false,"error_code":"SERVER_ERROR","code":"SERVER_ERROR","data":{},"message":"JSON 응답을 생성하지 못했습니다."}';
    }
    echo $json;
}

function apiSongPayload(array $song, array $options = []) {
    $id = (int)($song['id'] ?? $song['song_id'] ?? 0);
    $lyricsText = array_key_exists('lyrics_text', $song) ? ensureUtf8String($song['lyrics_text']) : null;
    $hasLyrics = $lyricsText !== null && trim($lyricsText) !== '';

    $payload = [
        'id' => $id,
        'song_id' => $id,
        'title' => ensureUtf8String($song['title'] ?? ''),
        'artist' => artistName($song['artist'] ?? ''),
        'album' => albumName($song['album'] ?? ''),
        'album_artist' => ensureUtf8String($song['album_artist'] ?? ''),
        'genre' => genreName($song['genre'] ?? ''),
        'year' => isset($song['year']) ? (int)$song['year'] : null,
        'track_no' => isset($song['track_no']) ? (int)$song['track_no'] : null,
        'disc_no' => isset($song['disc_no']) ? (int)$song['disc_no'] : null,
        'duration' => isset($song['duration']) ? (int)$song['duration'] : 0,
        'extension' => ensureUtf8String($song['extension'] ?? ''),
        'play_count' => isset($song['play_count']) ? (int)$song['play_count'] : 0,
        'has_lyrics' => $hasLyrics,
        'cover_url' => coverUrl($song),
        'stream_url' => 'stream.php?id=' . $id,
        'song_url' => 'song.php?id=' . $id,
        'lyrics_url' => 'api/get_lyrics.php?song_id=' . $id,
    ];

    if (!empty($options['include_lyrics'])) {
        $payload['lyrics'] = $lyricsText ?? '';
        $payload['lyrics_updated_at'] = ensureUtf8String($song['lyrics_updated_at'] ?? '');
    }

    return $payload;
}

function playlistDisplayScopeOptions18(): array {
    return [
        'private' => '개인용',
        'home' => '홈에 표시',
        'hidden' => '숨김',
    ];
}

function normalizePlaylistDisplayScope18($scope): string {
    $scope = trim((string)$scope);
    return array_key_exists($scope, playlistDisplayScopeOptions18()) ? $scope : 'private';
}

function playlistDisplayScopeLabel18($scope): string {
    $scope = normalizePlaylistDisplayScope18($scope);
    $options = playlistDisplayScopeOptions18();
    return $options[$scope] ?? $options['private'];
}

function getSetting($key, $default = null) {
    static $cache = [];
    if (array_key_exists($key, $cache)) return $cache[$key];
    $pdo = getDBConnection();
    $stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :setting_key LIMIT 1");
    $stmt->execute([':setting_key' => $key]);
    $value = $stmt->fetchColumn();
    if ($value === false || $value === null) {
        $cache[$key] = $default;
        return $default;
    }
    $cache[$key] = $value;
    return $value;
}

function setSetting($key, $value, $group = 'general') {
    $pdo = getDBConnection();
    $stmt = $pdo->prepare("
        INSERT INTO settings (setting_key, setting_value, setting_group)
        VALUES (:setting_key, :setting_value, :setting_group)
        ON DUPLICATE KEY UPDATE
            setting_value = VALUES(setting_value),
            setting_group = VALUES(setting_group),
            updated_at = CURRENT_TIMESTAMP
    ");
    return $stmt->execute([
        ':setting_key' => $key,
        ':setting_value' => $value,
        ':setting_group' => $group,
    ]);
}

function getSettingsByGroup($group) {
    $pdo = getDBConnection();
    $stmt = $pdo->prepare("SELECT setting_key, setting_value FROM settings WHERE setting_group = :setting_group ORDER BY setting_key ASC");
    $stmt->execute([':setting_group' => $group]);
    $settings = [];
    foreach ($stmt->fetchAll() as $row) {
        $settings[$row['setting_key']] = $row['setting_value'];
    }
    return $settings;
}

function settingBool($key, $default = true) {
    $value = getSetting($key, $default ? '1' : '0');
    return in_array((string)$value, ['1', 'true', 'yes', 'on'], true);
}

function settingInt($key, $default, $min = null, $max = null) {
    $value = (int)getSetting($key, (string)$default);
    if ($min !== null) $value = max($min, $value);
    if ($max !== null) $value = min($max, $value);
    return $value;
}

function getListDefaultLimit() {
    return settingInt('list_default_limit', 50, 10, 200);
}

function getScanPathSetting() {
    return trim((string)getSetting('scan_path', MUSIC_SCAN_PATH)) ?: MUSIC_SCAN_PATH;
}

function getAllowedExtensionsSetting() {
    $raw = trim((string)getSetting('scan_allowed_extensions', implode(',', ALLOWED_AUDIO_EXTENSIONS)));
    $items = array_filter(array_map(function ($item) {
        return strtolower(trim($item, " \t\n\r\0\x0B."));
    }, explode(',', $raw)));
    return !empty($items) ? array_values(array_unique($items)) : ALLOWED_AUDIO_EXTENSIONS;
}

function getBackupDir() {
    return dirname(__DIR__) . '/storage/backups';
}

function ensureBackupDir() {
    $dir = getBackupDir();
    if (!is_dir($dir)) mkdir($dir, 0777, true);
    return $dir;
}

function getCoverCacheDir() {
    return dirname(__DIR__) . '/storage/covers';
}

function ensureCoverCacheDir() {
    $dir = getCoverCacheDir();
    if (!is_dir($dir)) mkdir($dir, 0777, true);
    return $dir;
}

function coverExtensionFromMime($mime) {
    $mime = strtolower((string)$mime);
    if (strpos($mime, 'png') !== false) return 'png';
    if (strpos($mime, 'webp') !== false) return 'webp';
    if (strpos($mime, 'gif') !== false) return 'gif';
    return 'jpg';
}

function isPathInside($path, $baseDir) {
    $realPath = realpath($path) ?: $path;
    $realBase = realpath($baseDir) ?: $baseDir;
    if (!$realPath || !$realBase) return false;
    $realPath = rtrim(strtolower(str_replace('\\', '/', toUtf8Path($realPath))), '/');
    $realBase = rtrim(strtolower(str_replace('\\', '/', toUtf8Path($realBase))), '/');
    return $realPath === $realBase || strpos($realPath . '/', $realBase . '/') === 0;
}

function logMaintenance($actionType, $message, $status = 'success') {
    try {
        $pdo = getDBConnection();
        $stmt = $pdo->prepare("INSERT INTO maintenance_logs (action_type, message, status) VALUES (:action_type, :message, :status)");
        $stmt->execute([
            ':action_type' => $actionType,
            ':message' => $message,
            ':status' => $status,
        ]);
    } catch (Exception $e) {
    }
}

function sanitizeLogContext($context) {
    if (is_string($context) || is_numeric($context) || is_bool($context) || $context === null) {
        return is_string($context) ? limitLogText($context) : $context;
    }
    if (is_object($context)) {
        $context = method_exists($context, '__toString') ? (string)$context : get_class($context);
    }
    if (!is_array($context)) return limitLogText((string)$context);
    $blocked = [
        'password',
        'password_hash',
        'csrf_token',
        'session',
        'cookie',
        'authorization',
        'lyrics',
        'lyric',
        'note_body',
        'note_text',
        'memo_body',
        'raw_text',
        'clipboard_text',
    ];
    foreach ($context as $key => $value) {
        $lower = strtolower((string)$key);
        foreach ($blocked as $needle) {
            if (strpos($lower, $needle) !== false) {
                $context[$key] = '[redacted]';
                continue 2;
            }
        }
        if (is_array($value) || is_object($value)) {
            $context[$key] = sanitizeLogContext($value);
        } elseif (is_string($value)) {
            $context[$key] = limitLogText($value);
        }
    }
    return $context;
}

function writeAppLog($level, $type, $message, $context = [], $filePath = null) {
    try {
        $allowedLevels = ['info', 'warning', 'error', 'critical'];
        $level = in_array($level, $allowedLevels, true) ? $level : 'info';
        $type = limitLogText((string)$type, 120);
        $message = limitLogText($message, 2000);
        $filePath = $filePath !== null ? limitLogText($filePath, 500) : null;
        $context = sanitizeLogContext($context);
        $contextJson = null;
        if (!empty($context)) {
            $contextJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
            if ($contextJson === false) $contextJson = json_encode(['message' => 'context encode failed']);
        }
        $pdo = getDBConnection();
        if (in_array($level, ['warning', 'error', 'critical'], true)) {
            try {
                $dup = $pdo->prepare("
                    SELECT id
                    FROM app_logs
                    WHERE log_level = :log_level
                      AND log_type = :log_type
                      AND message = :message
                      AND created_at >= DATE_SUB(NOW(), INTERVAL 60 SECOND)
                    LIMIT 1
                ");
                $dup->execute([
                    ':log_level' => $level,
                    ':log_type' => $type,
                    ':message' => (string)$message,
                ]);
                if ($dup->fetchColumn()) return;
            } catch (Throwable $duplicateCheckError) {
            }
        }
        $stmt = $pdo->prepare("
            INSERT INTO app_logs (log_level, log_type, message, context, file_path, user_id, ip_address, user_agent)
            VALUES (:log_level, :log_type, :message, :context, :file_path, :user_id, :ip_address, :user_agent)
        ");
        $stmt->execute([
            ':log_level' => $level,
            ':log_type' => $type,
            ':message' => (string)$message,
            ':context' => $contextJson,
            ':file_path' => $filePath,
            ':user_id' => $_SESSION['admin_user_id'] ?? null,
            ':ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
            ':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
        ]);
    } catch (Throwable $e) {
        // Logging must never break the app.
    }
}

function getThemeClass() {
    $theme = getSetting('theme_mode', 'melon');
    $allowed = ['system', 'light', 'dark', 'melon', 'black'];
    if (!in_array($theme, $allowed, true)) $theme = 'melon';
    return 'theme-' . $theme;
}

function metaValue($value, $fallback) {
    $value = trim((string)($value ?? ''));
    return $value === '' ? $fallback : $value;
}

function artistName($value) {
    return metaValue($value, '알 수 없는 아티스트');
}

function albumName($value) {
    return metaValue($value, '알 수 없는 앨범');
}

function genreName($value) {
    return metaValue($value, '알 수 없는 장르');
}

function albumDescriptionKey($value) {
    return trim(ensureUtf8String((string)($value ?? '')));
}

function fetchAlbumDescription($albumName, $artistName = '') {
    $albumName = albumDescriptionKey($albumName);
    $artistName = albumDescriptionKey($artistName);
    if ($albumName === '') return null;
    try {
        $pdo = getDBConnection();
        $stmt = $pdo->prepare("
            SELECT id, album_name, artist_name, description, updated_by_admin, updated_at
            FROM album_descriptions
            WHERE album_name = :album_name
              AND artist_name = :artist_name
            LIMIT 1
        ");
        $stmt->execute([
            ':album_name' => $albumName,
            ':artist_name' => $artistName,
        ]);
        $row = $stmt->fetch();
        return $row ?: null;
    } catch (Throwable $e) {
        if (function_exists('writeAppLog')) {
            writeAppLog('warning', 'album_description_fetch_error', 'Album description fetch failed', [
                'album' => $albumName,
                'artist' => $artistName,
                'error' => $e->getMessage(),
            ]);
        }
        return null;
    }
}

function saveAlbumDescription($albumName, $artistName, $description, $adminUserId = null) {
    $albumName = albumDescriptionKey($albumName);
    $artistName = albumDescriptionKey($artistName);
    $description = trim(ensureUtf8String((string)($description ?? '')));
    if ($albumName === '') {
        throw new InvalidArgumentException('Album name is required.');
    }
    $pdo = getDBConnection();
    if ($description === '') {
        $stmt = $pdo->prepare("
            DELETE FROM album_descriptions
            WHERE album_name = :album_name
              AND artist_name = :artist_name
        ");
        $stmt->execute([
            ':album_name' => $albumName,
            ':artist_name' => $artistName,
        ]);
        return ['saved' => false, 'description' => '', 'updated_at' => null];
    }
    $stmt = $pdo->prepare("
        INSERT INTO album_descriptions (album_name, artist_name, description, updated_by_admin)
        VALUES (:album_name, :artist_name, :description, :updated_by_admin)
        ON DUPLICATE KEY UPDATE
            description = :description_update,
            updated_by_admin = :updated_by_admin_update,
            updated_at = CURRENT_TIMESTAMP
    ");
    $adminUserId = $adminUserId !== null ? (int)$adminUserId : null;
    $stmt->execute([
        ':album_name' => $albumName,
        ':artist_name' => $artistName,
        ':description' => $description,
        ':updated_by_admin' => $adminUserId,
        ':description_update' => $description,
        ':updated_by_admin_update' => $adminUserId,
    ]);
    return ['saved' => true, 'description' => $description, 'updated_at' => date('Y-m-d H:i:s')];
}

function normalizeMetaParam($value) {
    return trim((string)($value ?? ''));
}

function unknownMetaWhere($column) {
    return "($column IS NULL OR TRIM($column) = '')";
}

function metaWhereSql($column, $paramName, $value, &$params) {
    if ($value === '') return unknownMetaWhere($column);
    $params[$paramName] = $value;
    return "$column = $paramName";
}

function baseSongSelectSql() {
    return "
        SELECT s.id, s.title, s.artist, s.album, s.album_artist, s.genre, s.duration, s.extension,
               s.play_count, s.created_at, s.updated_at, s.track_no, s.disc_no, s.year,
               s.folder_name, s.relative_path,
               s.composer, s.comment, s.bitrate, s.sample_rate, s.channels,
               s.metadata_source, s.metadata_updated_at, s.last_metadata_scan_at, s.metadata_error, s.is_manual_edited,
               s.cover_hash, s.cover_cache_path, s.has_cover, s.cover_mime,
               IF(l.id IS NOT NULL, 1, 0) AS is_liked,
               COALESCE(r.rating, 0) AS user_rating,
               IF(n.note_text IS NOT NULL AND TRIM(n.note_text) != '', 1, 0) AS has_note
        FROM songs s
        LEFT JOIN liked_songs l ON s.id = l.song_id
        LEFT JOIN track_ratings r ON r.song_id = s.id
        LEFT JOIN track_notes n ON n.song_id = s.id
    ";
}

function fetchSongs($whereSql = 's.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', $params = [], $orderSql = 's.id DESC', $limit = null, $offset = 0) {
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . " WHERE " . $whereSql . " ORDER BY " . $orderSql;
    if ($limit === null) $limit = getListDefaultLimit();
    if ($limit !== null) {
        $sql .= " LIMIT " . max(1, (int)$limit);
        $offset = max(0, (int)$offset);
        if ($offset > 0) $sql .= " OFFSET " . $offset;
    }
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function countSongs($whereSql = 's.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', $params = []) {
    $pdo = getDBConnection();
    $countSql = "
        SELECT COUNT(DISTINCT s.id)
        FROM songs s
        LEFT JOIN liked_songs l ON s.id = l.song_id
        LEFT JOIN track_ratings r ON r.song_id = s.id
        LEFT JOIN track_notes n ON n.song_id = s.id
        WHERE " . $whereSql;
    $stmt = $pdo->prepare($countSql);
    $stmt->execute($params);
    return (int)$stmt->fetchColumn();
}

function pageUrlWith($changes = []) {
    $params = $_GET;
    foreach ($changes as $key => $value) {
        if ($value === null || $value === '') unset($params[$key]);
        else $params[$key] = $value;
    }
    return basename(currentScriptPath()) . '?' . http_build_query($params);
}

function renderSimplePager($page, $pages, $label = '페이지') {
    $page = max(1, (int)$page);
    $pages = max(1, (int)$pages);
    if ($pages <= 1) return;
    $start = max(1, $page - 2);
    $end = min($pages, $page + 2);
    ?>
    <nav class="search-pagination" aria-label="<?php echo h($label); ?>">
        <?php if ($page > 1): ?><a class="btn btn-secondary btn-sm" href="<?php echo h(pageUrlWith(['page' => $page - 1])); ?>">이전</a><?php endif; ?>
        <?php for ($i = $start; $i <= $end; $i++): ?>
            <a class="pager-link <?php echo $i === $page ? 'active' : ''; ?>" href="<?php echo h(pageUrlWith(['page' => $i])); ?>"><?php echo number_format($i); ?></a>
        <?php endfor; ?>
        <?php if ($page < $pages): ?><a class="btn btn-secondary btn-sm" href="<?php echo h(pageUrlWith(['page' => $page + 1])); ?>">다음</a><?php endif; ?>
    </nav>
    <?php
}

function searchTokens($query) {
    $query = trim(preg_replace('/\s+/u', ' ', (string)$query));
    if ($query === '') return [];
    return array_values(array_filter(explode(' ', $query), function ($token) {
        return trim($token) !== '';
    }));
}

function buildSongSearchWhere($query, &$params, $alias = 's') {
    $tokens = searchTokens($query);
    if (empty($tokens)) return '1 = 0';
    $clauses = [];
    $columns = ['title', 'artist', 'album', 'album_artist', 'genre', 'year', 'composer'];
    foreach ($tokens as $idx => $token) {
        $parts = [];
        foreach ($columns as $column) {
            $name = ':q' . $idx . '_' . $column;
            $parts[] = $alias . '.' . $column . ' LIKE ' . $name;
            $params[$name] = '%' . $token . '%';
        }
        $lyricsName = ':q' . $idx . '_lyrics';
        $parts[] = 'ly.lyrics_text LIKE ' . $lyricsName;
        $params[$lyricsName] = '%' . $token . '%';
        $noteName = ':q' . $idx . '_note';
        $parts[] = 'n.note_text LIKE ' . $noteName;
        $params[$noteName] = '%' . $token . '%';
        $clauses[] = '(' . implode(' OR ', $parts) . ')';
    }
    return implode(' AND ', $clauses);
}

function fetchSearchSongs($query, $limit = 100) {
    $pdo = getDBConnection();
    $params = [];
    $where = buildSongSearchWhere($query, $params, 's');
    $sql = baseSongSelectSql() . "
        LEFT JOIN lyrics ly ON ly.song_id = s.id
        WHERE s.is_missing = 0 AND $where
        ORDER BY s.play_count DESC, s.title ASC
        LIMIT " . max(1, (int)$limit);
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function getRecentlyPlayedSongs($limit = 20) {
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . "
        INNER JOIN (
            SELECT song_id, MAX(played_at) AS last_played_at
            FROM play_history
            GROUP BY song_id
        ) recent ON recent.song_id = s.id
        WHERE s.is_missing = 0
        ORDER BY recent.last_played_at DESC
        LIMIT " . max(1, (int)$limit);
    return $pdo->query($sql)->fetchAll();
}

function getTopPlayedSongs($limit = 20) {
    return fetchSongs('s.is_missing = 0', [], 's.play_count DESC, s.title ASC', $limit);
}

function getFavoriteGenres($limit = 10) {
    $pdo = getDBConnection();
    $sql = "
        SELECT s.genre, COUNT(*) AS score
        FROM play_history ph
        INNER JOIN songs s ON s.id = ph.song_id
        WHERE s.is_missing = 0 AND s.genre IS NOT NULL AND TRIM(s.genre) != ''
        GROUP BY s.genre
        ORDER BY score DESC
        LIMIT " . max(1, (int)$limit);
    return $pdo->query($sql)->fetchAll();
}

function getFavoriteArtists($limit = 10) {
    $pdo = getDBConnection();
    $sql = "
        SELECT s.artist, COUNT(*) AS score
        FROM play_history ph
        INNER JOIN songs s ON s.id = ph.song_id
        WHERE s.is_missing = 0 AND s.artist IS NOT NULL AND TRIM(s.artist) != ''
        GROUP BY s.artist
        ORDER BY score DESC
        LIMIT " . max(1, (int)$limit);
    return $pdo->query($sql)->fetchAll();
}

function getNeverPlayedSongs($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND COALESCE(s.play_count, 0) = 0', [], 's.id DESC', $limit);
}

function getOldLikedSongs($limit = 50) {
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . "
        LEFT JOIN (
            SELECT song_id, MAX(played_at) AS last_played_at
            FROM play_history
            GROUP BY song_id
        ) recent ON recent.song_id = s.id
        WHERE s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND l.id IS NOT NULL
        ORDER BY recent.last_played_at IS NULL DESC, recent.last_played_at ASC, l.created_at DESC
        LIMIT " . max(1, (int)$limit);
    return $pdo->query($sql)->fetchAll();
}

function recommendationDecorateSongs($songs, $reason) {
    return array_map(function ($song) use ($reason) {
        $song['recommend_reason'] = $reason;
        return $song;
    }, is_array($songs) ? $songs : []);
}

function recommendationMergeUnique(&$picked, &$seen, $songs, $reason, $limit) {
    foreach (recommendationDecorateSongs($songs, $reason) as $song) {
        if (count($picked) >= $limit) break;
        $id = (int)($song['id'] ?? 0);
        if ($id <= 0 || isset($seen[$id])) continue;
        $seen[$id] = true;
        $picked[] = $song;
    }
}

function getTopRatedSongs($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND COALESCE(r.rating, 0) >= 4', [], 'r.rating DESC, s.play_count DESC, s.title ASC', $limit);
}

function getLikedSongsForRecommendation($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND l.id IS NOT NULL', [], 'l.created_at DESC, s.play_count DESC', $limit);
}

function getRecentFrequentSongs($days = 7, $limit = 50) {
    $days = max(1, min(365, (int)$days));
    $limit = max(1, (int)$limit);
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . "
        INNER JOIN (
            SELECT song_id, COUNT(*) AS recent_score, MAX(played_at) AS last_played_at
            FROM play_history
            WHERE played_at >= DATE_SUB(NOW(), INTERVAL $days DAY)
            GROUP BY song_id
        ) recent ON recent.song_id = s.id
        WHERE s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0
        ORDER BY recent.recent_score DESC, recent.last_played_at DESC
        LIMIT $limit
    ";
    return $pdo->query($sql)->fetchAll();
}

function getUnratedPopularSongs($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND COALESCE(s.play_count, 0) > 0 AND COALESCE(r.rating, 0) = 0', [], 's.play_count DESC, s.title ASC', $limit);
}

function getHighRatedLowPlaySongs($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND COALESCE(r.rating, 0) >= 4', [], 's.play_count ASC, r.rating DESC, s.id DESC', $limit);
}

function getRecentAddedUnplayedSongs($limit = 50) {
    return fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND COALESCE(s.play_count, 0) = 0', [], 's.id DESC', $limit);
}

function getSongsByTopTags($limit = 50) {
    $limit = max(1, (int)$limit);
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . "
        INNER JOIN song_tags st ON st.song_id = s.id
        INNER JOIN (
            SELECT st2.tag_id, COUNT(*) AS tag_score
            FROM play_history ph
            INNER JOIN song_tags st2 ON st2.song_id = ph.song_id
            WHERE ph.played_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)
            GROUP BY st2.tag_id
            ORDER BY tag_score DESC
            LIMIT 5
        ) fav ON fav.tag_id = st.tag_id
        WHERE s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0
        ORDER BY fav.tag_score DESC, s.play_count ASC, s.id DESC
        LIMIT $limit
    ";
    return $pdo->query($sql)->fetchAll();
}

function getTimeContextSongs($limit = 50) {
    $hour = (int)date('G');
    if ($hour >= 5 && $hour < 11) {
        $start = 5; $end = 10; $reason = '이 시간대에 자주 듣던 곡';
    } elseif ($hour >= 11 && $hour < 17) {
        $start = 11; $end = 16; $reason = '낮 시간대 청취 패턴 기반';
    } elseif ($hour >= 17 && $hour < 22) {
        $start = 17; $end = 21; $reason = '저녁에 자주 듣던 곡';
    } else {
        $start = 22; $end = 4; $reason = '밤에 자주 듣던 곡';
    }
    $limit = max(1, (int)$limit);
    $pdo = getDBConnection();
    $params = [':start_hour' => $start, ':end_hour' => $end];
    $hourSql = $start <= $end
        ? 'HOUR(played_at) BETWEEN :start_hour AND :end_hour'
        : '(HOUR(played_at) >= :start_hour OR HOUR(played_at) <= :end_hour)';
    $sql = baseSongSelectSql() . "
        INNER JOIN (
            SELECT song_id, COUNT(*) AS time_score, MAX(played_at) AS last_played_at
            FROM play_history
            WHERE played_at >= DATE_SUB(NOW(), INTERVAL 180 DAY)
              AND $hourSql
            GROUP BY song_id
        ) timepick ON timepick.song_id = s.id
        WHERE s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0
        ORDER BY timepick.time_score DESC, timepick.last_played_at DESC
        LIMIT $limit
    ";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return recommendationDecorateSongs($stmt->fetchAll(), $reason);
}

function getSmartRecommendedSongs($limit = 20) {
    $limit = max(1, (int)$limit);
    $picked = [];
    $seen = [];
    $addSongs = function ($songs, $reason) use (&$picked, &$seen, $limit) {
        recommendationMergeUnique($picked, $seen, $songs, $reason, $limit);
    };

    $favoriteGenres = getFavoriteGenres(3);
    foreach ($favoriteGenres as $genre) {
        $addSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND s.genre = :genre', [':genre' => $genre['genre']], 's.play_count ASC, s.id DESC', $limit), '좋아하는 장르 기반');
    }

    $favoriteArtists = getFavoriteArtists(3);
    foreach ($favoriteArtists as $artist) {
        $addSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND s.artist = :artist', [':artist' => $artist['artist']], 's.play_count DESC, s.id DESC', $limit), '최근 자주 들은 아티스트 기반');
    }

    $addSongs(getSongsByTopTags($limit), '자주 들은 태그 기반');
    $addSongs(getHighRatedLowPlaySongs($limit), '별점은 높은데 덜 들은 곡');
    $addSongs(getOldLikedSongs($limit), '오래 안 들은 좋아요 곡');
    $addSongs(getNeverPlayedSongs($limit), '아직 재생하지 않은 곡');
    $addSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $limit), '랜덤 fallback 추천');
    return $picked;
}

function recommendationTakeUniqueSongs48(array $songs, array &$globalSeen, int $limit, string $reason): array {
    $picked = [];
    foreach ($songs as $song) {
        if (count($picked) >= $limit) break;
        $id = (int)($song['id'] ?? 0);
        if ($id <= 0 || isset($globalSeen[$id])) continue;
        $globalSeen[$id] = true;
        if (empty($song['recommend_reason'])) {
            $song['recommend_reason'] = $reason;
        }
        $picked[] = $song;
    }
    return $picked;
}

function recommendationSectionPayload48(string $key, string $title, string $description, array $songs, string $moreUrl = ''): array {
    return [
        'key' => $key,
        'title' => $title,
        'description' => $description,
        'songs' => $songs,
        'more_url' => $moreUrl,
        'count' => count($songs),
    ];
}

function getLikedGenreStats48(int $limit = 5): array {
    $limit = max(1, min(10, $limit));
    $pdo = getDBConnection();
    $sql = "
        SELECT s.genre, COUNT(*) AS score
        FROM liked_songs l
        INNER JOIN songs s ON s.id = l.song_id
        WHERE COALESCE(s.is_missing, 0) = 0
          AND COALESCE(s.is_hidden, 0) = 0
          AND s.genre IS NOT NULL
          AND TRIM(s.genre) != ''
        GROUP BY s.genre
        ORDER BY score DESC, s.genre ASC
        LIMIT " . $limit;
    return $pdo->query($sql)->fetchAll();
}

function getLikedArtistStats48(int $limit = 5): array {
    $limit = max(1, min(10, $limit));
    $pdo = getDBConnection();
    $sql = "
        SELECT s.artist, COUNT(*) AS score
        FROM liked_songs l
        INNER JOIN songs s ON s.id = l.song_id
        WHERE COALESCE(s.is_missing, 0) = 0
          AND COALESCE(s.is_hidden, 0) = 0
          AND s.artist IS NOT NULL
          AND TRIM(s.artist) != ''
        GROUP BY s.artist
        ORDER BY score DESC, s.artist ASC
        LIMIT " . $limit;
    return $pdo->query($sql)->fetchAll();
}

function getRecommendedByMeta48(string $column, array $values, int $limit, string $orderSql = 's.play_count ASC, s.id DESC'): array {
    $allowed = ['genre' => 's.genre', 'artist' => 's.artist', 'album' => 's.album'];
    if (!isset($allowed[$column])) return [];
    $values = array_values(array_unique(array_filter(array_map('normalizeMetaParam', $values), fn($v) => $v !== '')));
    if (empty($values)) return [];
    $limit = max(1, min(30, $limit));
    $picked = [];
    foreach (array_slice($values, 0, 5) as $idx => $value) {
        if (count($picked) >= $limit) break;
        $param = ':meta_' . $idx;
        $songs = fetchSongs(
            's.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0 AND ' . $allowed[$column] . ' = ' . $param,
            [$param => $value],
            $orderSql,
            max(4, $limit)
        );
        foreach ($songs as $song) {
            if (count($picked) >= $limit) break;
            $id = (int)($song['id'] ?? 0);
            if ($id <= 0 || isset($picked[$id])) continue;
            $picked[$id] = $song;
        }
    }
    return array_values($picked);
}

function getPersonalizedRecommendationSections48(int $limitPerSection = 8): array {
    $limitPerSection = max(3, min(12, $limitPerSection));
    $candidateLimit = max(12, $limitPerSection * 3);
    $sections = [];
    $seen = [];

    $addSection = function (string $key, string $title, string $description, array $songs, string $reason, string $moreUrl = '') use (&$sections, &$seen, $limitPerSection) {
        $picked = recommendationTakeUniqueSongs48($songs, $seen, $limitPerSection, $reason);
        if (!empty($picked)) {
            $sections[] = recommendationSectionPayload48($key, $title, $description, $picked, $moreUrl);
        }
    };

    $addSection(
        'recent_genre',
        '최근 취향과 비슷한 곡',
        '최근 자주 들은 장르를 기준으로 덜 들은 곡을 골랐습니다.',
        getRecommendedByMeta48('genre', array_column(getFavoriteGenres(4), 'genre'), $candidateLimit, 's.play_count ASC, s.id DESC'),
        '최근 자주 들은 장르와 비슷해요',
        'auto_playlist.php?type=tag_mix'
    );

    $addSection(
        'recent_artist',
        '자주 듣는 아티스트 기반',
        '최근 많이 들은 아티스트의 다른 곡을 모았습니다.',
        getRecommendedByMeta48('artist', array_column(getFavoriteArtists(4), 'artist'), $candidateLimit, 's.play_count ASC, s.album ASC, s.track_no ASC, s.id DESC'),
        '자주 듣는 아티스트 기반 추천',
        'artists.php'
    );

    $addSection(
        'liked_genre',
        '좋아요한 장르 기반',
        '좋아요를 누른 곡의 장르와 가까운 곡입니다.',
        getRecommendedByMeta48('genre', array_column(getLikedGenreStats48(4), 'genre'), $candidateLimit, 's.play_count ASC, s.id DESC'),
        '좋아요한 장르와 비슷해요',
        'liked.php'
    );

    $addSection(
        'liked_artist',
        '좋아요한 아티스트 기반',
        '좋아요한 아티스트의 덜 들은 곡을 우선 표시합니다.',
        getRecommendedByMeta48('artist', array_column(getLikedArtistStats48(4), 'artist'), $candidateLimit, 's.play_count ASC, s.id DESC'),
        '좋아요한 아티스트 기반 추천',
        'liked.php'
    );

    $addSection(
        'rated_hidden',
        '별점은 높고 덜 들은 곡',
        '평가는 좋지만 자주 재생하지 않은 곡입니다.',
        getHighRatedLowPlaySongs($candidateLimit),
        '별점이 높은데 덜 들었어요',
        'listening_notes.php?filter=top_rated'
    );

    $addSection(
        'old_liked',
        '오랜만에 다시 듣기 좋은 곡',
        '좋아요한 뒤 한동안 멀어진 곡을 다시 꺼냈습니다.',
        getOldLikedSongs($candidateLimit),
        '오랜만에 다시 듣기 좋아요',
        'liked.php'
    );

    $addSection(
        'new_unplayed',
        '최근 추가했지만 아직 안 들은 곡',
        '새로 들어온 곡 중 재생 기록이 적은 곡입니다.',
        getRecentAddedUnplayedSongs($candidateLimit),
        '최근 추가된 곡이에요',
        'new.php'
    );

    $addSection(
        'tag_mix',
        '태그 취향 믹스',
        '최근 청취 태그와 연결되는 곡을 골랐습니다.',
        getSongsByTopTags($candidateLimit),
        '자주 듣는 태그 기반 추천',
        'auto_playlist.php?type=tag_mix'
    );

    if (count($sections) < 3) {
        $addSection(
            'popular_fallback',
            '많이 들은 곡에서 시작하기',
            '취향 데이터가 부족해 인기 곡을 먼저 보여드립니다.',
            fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 's.play_count DESC, s.id DESC', $candidateLimit),
            '데이터 부족으로 인기 곡 추천',
            'popular.php'
        );
    }

    if (count($sections) < 3) {
        $addSection(
            'random_fallback',
            '랜덤 추천',
            '아직 추천 데이터가 부족해 보관함에서 가볍게 골랐습니다.',
            fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $candidateLimit),
            '랜덤 추천',
            'recommend.php'
        );
    }

    return $sections;
}

function getPersonalizedRecommendedSongs48(int $limit = 20): array {
    $limit = max(1, min(50, $limit));
    $picked = [];
    $seen = [];
    foreach (getPersonalizedRecommendationSections48(min(8, $limit)) as $section) {
        foreach ($section['songs'] as $song) {
            if (count($picked) >= $limit) break 2;
            $id = (int)($song['id'] ?? 0);
            if ($id <= 0 || isset($seen[$id])) continue;
            $seen[$id] = true;
            $picked[] = $song;
        }
    }
    if (count($picked) < $limit) {
        foreach (fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $limit) as $song) {
            if (count($picked) >= $limit) break;
            $id = (int)($song['id'] ?? 0);
            if ($id <= 0 || isset($seen[$id])) continue;
            $seen[$id] = true;
            $song['recommend_reason'] = $song['recommend_reason'] ?? '랜덤 추천';
            $picked[] = $song;
        }
    }
    return $picked;
}

function getRecentAlbumStats16(int $limit = 5): array {
    $limit = max(1, min(10, $limit));
    $pdo = getDBConnection();
    $sql = "
        SELECT s.album, COUNT(*) AS score, MAX(ph.played_at) AS last_played_at
        FROM play_history ph
        INNER JOIN songs s ON s.id = ph.song_id
        WHERE COALESCE(s.is_missing, 0) = 0
          AND COALESCE(s.is_hidden, 0) = 0
          AND s.album IS NOT NULL
          AND TRIM(s.album) != ''
        GROUP BY s.album
        ORDER BY last_played_at DESC, score DESC, s.album ASC
        LIMIT " . $limit;
    return $pdo->query($sql)->fetchAll();
}

function getTodayRecommendationSongs16(int $limit = 6): array {
    $limit = max(3, min(8, $limit));
    $candidateLimit = max(10, $limit * 3);
    $picked = [];
    $seen = [];

    $addSongs = function (array $songs, string $reason) use (&$picked, &$seen, $limit) {
        foreach ($songs as $song) {
            if (count($picked) >= $limit) break;
            $id = (int)($song['id'] ?? 0);
            if ($id <= 0 || isset($seen[$id])) continue;
            $seen[$id] = true;
            if (empty($song['recommend_reason'])) {
                $song['recommend_reason'] = $reason;
            }
            $picked[] = $song;
        }
    };

    $addSongs(getTimeContextSongs($candidateLimit), '지금 시간대에 어울려요');
    $addSongs(
        getRecommendedByMeta48('genre', array_column(getFavoriteGenres(3), 'genre'), $candidateLimit, 's.play_count ASC, s.id DESC'),
        '최근 자주 들은 장르 기반'
    );
    $addSongs(
        getRecommendedByMeta48('artist', array_column(getFavoriteArtists(3), 'artist'), $candidateLimit, 's.play_count ASC, s.album ASC, s.track_no ASC, s.id DESC'),
        '자주 듣는 아티스트의 다른 곡'
    );
    $addSongs(
        getRecommendedByMeta48('genre', array_column(getLikedGenreStats48(3), 'genre'), $candidateLimit, 's.play_count ASC, s.id DESC'),
        '좋아요한 곡과 비슷한 장르'
    );
    $addSongs(
        getRecommendedByMeta48('album', array_column(getRecentAlbumStats16(3), 'album'), $candidateLimit, 's.track_no ASC, s.id DESC'),
        '최근 들은 앨범에서 이어듣기'
    );
    $addSongs(getHighRatedLowPlaySongs($candidateLimit), '별점은 높은데 덜 들었어요');
    $addSongs(getOldLikedSongs($candidateLimit), '오랜만에 다시 들으면 좋아요');

    if (count($picked) < $limit) {
        $addSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 's.play_count DESC, s.id DESC', $candidateLimit), '많이 들은 곡 기반 추천');
    }
    if (count($picked) < $limit) {
        $addSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $candidateLimit), '보관함에서 가볍게 골랐어요');
    }

    return $picked;
}

function getAutoMixSongs17(int $limit = 20): array {
    $limit = max(10, min(30, (int)$limit));
    $candidateLimit = max($limit, min(50, $limit * 2));
    $picked = [];
    $seen = [];

    $addSongs = function (array $songs, string $reason) use (&$picked, &$seen, $limit) {
        foreach ($songs as $song) {
            if (count($picked) >= $limit) break;
            $id = (int)($song['id'] ?? 0);
            if ($id <= 0 || isset($seen[$id])) continue;
            if (!empty($song['is_missing']) || !empty($song['is_hidden'])) continue;
            $seen[$id] = true;
            if (empty($song['recommend_reason'])) {
                $song['recommend_reason'] = $reason;
            }
            $picked[] = $song;
        }
    };

    $addSongs(getTodayRecommendationSongs16(min(8, $candidateLimit)), '오늘의 추천 기반');
    $addSongs(getPersonalizedRecommendedSongs48($candidateLimit), '청취 패턴 기반');

    $sources = [
        'recent_top' => '최근 자주 들은 곡 기반',
        'liked' => '좋아요한 곡 기반',
        'top_rated' => '별점 높은 곡 기반',
        'monthly_top' => '최근 30일 많이 들은 곡 기반',
        'old_liked' => '오래 안 들은 좋아요 곡',
        'tag_mix' => '자주 듣는 태그 기반',
        'hidden_gems' => '숨은 명곡 추천',
    ];

    foreach ($sources as $type => $reason) {
        if (count($picked) >= $limit) break;
        $addSongs(getAutoPlaylistSongs($type, min(30, $candidateLimit)), $reason);
    }

    if (count($picked) < $limit) {
        $addSongs(
            fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 's.play_count DESC, s.updated_at DESC, s.id DESC', $candidateLimit),
            '많이 들은 곡 기반'
        );
    }
    if (count($picked) < $limit) {
        $addSongs(
            fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 's.id DESC', $candidateLimit),
            '최근 추가 곡 기반'
        );
    }

    return array_slice($picked, 0, $limit);
}

function renderTodayRecommendationSection16(array $songs) {
    ?>
    <section class="today-recommend-section">
        <div class="section-heading-row today-recommend-heading">
            <div>
                <span class="section-kicker">오늘의 추천</span>
                <h2 class="section-title">지금 듣기 좋은 곡</h2>
            </div>
            <a class="section-more-link" href="recommend.php">추천 더보기</a>
        </div>
        <?php if (empty($songs)): ?>
            <div class="empty-action-state">
                <i class="fa-solid fa-wand-magic-sparkles"></i>
                <strong>아직 추천할 곡이 부족합니다.</strong>
                <span>몇 곡을 재생하거나 좋아요를 누르면 홈 추천이 더 정확해집니다.</span>
                <a class="btn btn-secondary btn-sm" href="index.php?view=all">전체 음악 보기</a>
            </div>
        <?php else: ?>
            <?php $primary = $songs[0]; $primaryLiked = !empty($primary['is_liked']); ?>
            <div class="today-recommend-layout">
                <article class="today-recommend-primary song-item"
                         data-id="<?php echo h($primary['id']); ?>"
                         data-title="<?php echo h($primary['title']); ?>"
                         data-artist="<?php echo h(artistName($primary['artist'] ?? '')); ?>"
                         data-album="<?php echo h(albumName($primary['album'] ?? '')); ?>"
                         data-duration="<?php echo h($primary['duration'] ?? 0); ?>"
                         data-cover-url="<?php echo h(coverUrl($primary)); ?>"
                         data-liked="<?php echo $primaryLiked ? '1' : '0'; ?>">
                    <img src="<?php echo h(coverUrl($primary)); ?>" alt="" class="today-recommend-cover" loading="lazy" width="176" height="176" onerror="this.onerror=null;this.src='assets/img/default-cover.svg';">
                    <div class="today-recommend-main">
                        <span class="recommend-reason-badge"><i class="fa-solid fa-lightbulb"></i> <?php echo h($primary['recommend_reason'] ?? '오늘의 추천'); ?></span>
                        <a href="song.php?id=<?php echo h($primary['id']); ?>" class="today-recommend-title"><?php echo h($primary['title']); ?></a>
                        <p><?php echo h(artistName($primary['artist'] ?? '')); ?><?php echo !empty($primary['album']) ? ' · ' . h(albumName($primary['album'])) : ''; ?></p>
                        <div class="today-recommend-actions">
                            <button type="button" class="btn btn-primary play-row-btn today-recommend-play-btn"><i class="fa-solid fa-play"></i> 바로 재생</button>
                            <button type="button" class="btn btn-secondary add-next-btn"><i class="fa-solid fa-list"></i> 대기열 추가</button>
                            <button type="button" class="like-btn <?php echo $primaryLiked ? 'liked' : ''; ?>" title="<?php echo $primaryLiked ? '좋아요 취소' : '좋아요'; ?>" aria-label="<?php echo $primaryLiked ? '좋아요 취소' : '좋아요'; ?>">
                                <i class="<?php echo $primaryLiked ? 'fas fa-heart' : 'far fa-heart'; ?>"></i>
                            </button>
                            <a class="btn btn-secondary" href="song.php?id=<?php echo h($primary['id']); ?>"><i class="fa-solid fa-circle-info"></i> 상세</a>
                        </div>
                    </div>
                </article>

                <div class="today-recommend-list" aria-label="추천 후보">
                    <?php foreach (array_slice($songs, 1, 4) as $song): ?>
                        <?php $isLiked = !empty($song['is_liked']); ?>
                        <article class="today-recommend-mini song-item"
                                 data-id="<?php echo h($song['id']); ?>"
                                 data-title="<?php echo h($song['title']); ?>"
                                 data-artist="<?php echo h(artistName($song['artist'] ?? '')); ?>"
                                 data-album="<?php echo h(albumName($song['album'] ?? '')); ?>"
                                 data-duration="<?php echo h($song['duration'] ?? 0); ?>"
                                 data-cover-url="<?php echo h(coverUrl($song)); ?>"
                                 data-liked="<?php echo $isLiked ? '1' : '0'; ?>">
                            <button type="button" class="play-row-btn compact-play" title="재생" aria-label="재생"><i class="fa-solid fa-play"></i></button>
                            <img src="<?php echo h(coverUrl($song)); ?>" alt="" class="compact-cover" loading="lazy" width="48" height="48" onerror="this.onerror=null;this.src='assets/img/default-cover.svg';">
                            <div class="today-recommend-mini-meta">
                                <a href="song.php?id=<?php echo h($song['id']); ?>"><?php echo h($song['title']); ?></a>
                                <span><?php echo h(artistName($song['artist'] ?? '')); ?></span>
                                <?php if (!empty($song['recommend_reason'])): ?><small><?php echo h($song['recommend_reason']); ?></small><?php endif; ?>
                            </div>
                            <div class="compact-song-actions">
                                <button type="button" class="like-btn <?php echo $isLiked ? 'liked' : ''; ?>" title="<?php echo $isLiked ? '좋아요 취소' : '좋아요'; ?>" aria-label="<?php echo $isLiked ? '좋아요 취소' : '좋아요'; ?>"><i class="<?php echo $isLiked ? 'fas fa-heart' : 'far fa-heart'; ?>"></i></button>
                                <button type="button" class="add-next-btn" title="대기열 추가" aria-label="대기열 추가"><i class="fa-solid fa-list"></i></button>
                            </div>
                        </article>
                    <?php endforeach; ?>
                </div>
            </div>
        <?php endif; ?>
    </section>
    <?php
}

function getAutoPlaylistDefinitions() {
    return [
        'recent_top' => ['title' => '최근 자주 듣는 곡', 'description' => '최근 7일 재생 기록을 기준으로 자주 들은 곡을 모았습니다.'],
        'monthly_top' => ['title' => '최근 30일 많이 들은 곡', 'description' => '이번 달 가까운 취향을 재생 기록으로 정리합니다.'],
        'top_rated' => ['title' => '별점 높은 곡', 'description' => '4점 이상 평가한 곡을 우선으로 모았습니다.'],
        'liked' => ['title' => '좋아요한 곡', 'description' => '좋아요한 곡을 최근 추가순으로 정리합니다.'],
        'old_liked' => ['title' => '오래 안 들은 좋아요 곡', 'description' => '좋아했지만 한동안 멀어진 곡을 다시 꺼냅니다.'],
        'recent_added' => ['title' => '최근 추가된 곡', 'description' => '새로 스캔된 음악을 빠르게 들어봅니다.'],
        'never_played' => ['title' => '아직 한 번도 안 들은 곡', 'description' => '보관함에 있지만 아직 재생하지 않은 곡입니다.'],
        'unrated_popular' => ['title' => '많이 들었지만 별점 없는 곡', 'description' => '자주 들었지만 아직 평가하지 않은 곡입니다.'],
        'tag_mix' => ['title' => '자주 듣는 태그 믹스', 'description' => '최근 청취 태그와 비슷한 곡을 추천합니다.'],
        'time_context' => ['title' => '지금 시간대 추천', 'description' => '현재 시간대에 자주 듣던 곡을 기반으로 추천합니다.'],
        'hidden_gems' => ['title' => '숨은 명곡 추천', 'description' => '별점은 높지만 재생 횟수가 낮은 곡을 찾아냅니다.'],
        'random_mix' => ['title' => '랜덤 믹스', 'description' => '보관함에서 무작위로 섞은 가벼운 믹스입니다.'],
    ];
}

function getAutoPlaylistSongs($type, $limit = 50) {
    $limit = max(1, min(100, (int)$limit));
    switch ($type) {
        case 'recent_top':
            $songs = recommendationDecorateSongs(getRecentFrequentSongs(7, $limit), '최근 7일 자주 들은 곡');
            break;
        case 'monthly_top':
            $songs = recommendationDecorateSongs(getRecentFrequentSongs(30, $limit), '최근 30일 많이 들은 곡');
            break;
        case 'top_rated':
            $songs = recommendationDecorateSongs(getTopRatedSongs($limit), '별점 4점 이상');
            break;
        case 'liked':
            $songs = recommendationDecorateSongs(getLikedSongsForRecommendation($limit), '좋아요한 곡');
            break;
        case 'recent_added':
            $songs = recommendationDecorateSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 's.id DESC', $limit), '최근 추가된 곡');
            break;
        case 'old_liked':
            $songs = recommendationDecorateSongs(getOldLikedSongs($limit), '오래 안 들은 좋아요 곡');
            break;
        case 'never_played':
            $songs = recommendationDecorateSongs(getNeverPlayedSongs($limit), '아직 한 번도 안 들은 곡');
            break;
        case 'unrated_popular':
            $songs = recommendationDecorateSongs(getUnratedPopularSongs($limit), '많이 들었지만 별점 없는 곡');
            break;
        case 'tag_mix':
            $songs = recommendationDecorateSongs(getSongsByTopTags($limit), '자주 듣는 태그 기반');
            break;
        case 'time_context':
            $songs = getTimeContextSongs($limit);
            break;
        case 'hidden_gems':
            $songs = recommendationDecorateSongs(getHighRatedLowPlaySongs($limit), '별점은 높은데 덜 들은 곡');
            break;
        case 'random_mix':
        default:
            $songs = recommendationDecorateSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $limit), '랜덤 믹스');
            break;
    }
    if (empty($songs) && $type !== 'random_mix') {
        return recommendationDecorateSongs(fetchSongs('s.is_missing = 0 AND COALESCE(s.is_hidden, 0) = 0', [], 'RAND()', $limit), '데이터 부족으로 랜덤 추천');
    }
    return $songs;
}

function getLibraryStats() {
    $pdo = getDBConnection();
    return [
        'songs' => (int)$pdo->query("SELECT COUNT(*) FROM songs WHERE is_missing = 0")->fetchColumn(),
        'artists' => (int)$pdo->query("SELECT COUNT(DISTINCT COALESCE(NULLIF(TRIM(artist), ''), '__unknown__')) FROM songs WHERE is_missing = 0")->fetchColumn(),
        'albums' => (int)$pdo->query("SELECT COUNT(DISTINCT CONCAT(COALESCE(NULLIF(TRIM(album), ''), '__unknown__'), '||', COALESCE(NULLIF(TRIM(artist), ''), '__unknown__'))) FROM songs WHERE is_missing = 0")->fetchColumn(),
        'genres' => (int)$pdo->query("SELECT COUNT(DISTINCT COALESCE(NULLIF(TRIM(genre), ''), '__unknown__')) FROM songs WHERE is_missing = 0")->fetchColumn(),
        'playlists' => (int)$pdo->query("SELECT COUNT(*) FROM playlists")->fetchColumn(),
        'plays' => (int)$pdo->query("SELECT COALESCE(SUM(play_count), 0) FROM songs")->fetchColumn(),
        'missing' => (int)$pdo->query("SELECT COUNT(*) FROM songs WHERE is_missing = 1")->fetchColumn(),
        'metadata_missing' => (int)$pdo->query("
            SELECT COUNT(*) FROM songs
            WHERE title IS NULL OR TRIM(title) = ''
               OR artist IS NULL OR TRIM(artist) = ''
               OR album IS NULL OR TRIM(album) = ''
               OR genre IS NULL OR TRIM(genre) = ''
               OR year IS NULL OR TRIM(year) = ''
               OR duration IS NULL OR duration <= 0
               OR cover_hash IS NULL OR TRIM(cover_hash) = ''
        ")->fetchColumn(),
    ];
}

function renderStatGrid($items) {
    ?>
    <div class="library-stat-grid">
        <?php foreach ($items as $item): ?>
            <div class="library-stat-card">
                <span class="library-stat-label"><?php echo h($item['label']); ?></span>
                <strong class="library-stat-value"><?php echo h($item['value']); ?></strong>
            </div>
        <?php endforeach; ?>
    </div>
    <?php
}

function renderPageActions() {
    ?>
    <div class="list-play-actions">
        <button type="button" class="btn btn-primary btn-play-all"><i class="fas fa-play"></i> 전체 재생</button>
        <button type="button" class="btn btn-secondary btn-shuffle-all"><i class="fas fa-random"></i> 셔플 재생</button>
        <button type="button" class="btn btn-secondary btn-add-page-to-queue"><i class="fas fa-list"></i> 대기열 추가</button>
    </div>
    <?php
}

function renderMiniSongSection($title, $songs, $moreUrl = null, $sectionKey = null) {
    $sectionAttrs = '';
    if ($sectionKey !== null && $sectionKey !== '') {
        $safeKey = preg_replace('/[^a-z0-9_\\-]/i', '', (string)$sectionKey);
        if ($safeKey !== '') {
            $sectionAttrs = ' data-home-section="' . h($safeKey) . '" data-home-label="' . h($title) . '"';
        }
    }
    ?>
    <section class="home-section"<?php echo $sectionAttrs; ?>>
        <div class="section-heading-row">
            <h2 class="section-title"><?php echo h($title); ?></h2>
            <?php if ($moreUrl): ?><a class="section-more-link" href="<?php echo h($moreUrl); ?>">더보기</a><?php endif; ?>
        </div>
        <?php if (empty($songs)): ?>
            <p class="empty-text">표시할 곡이 없습니다.</p>
        <?php else: ?>
            <div class="compact-song-grid">
                <?php foreach ($songs as $song): ?>
                    <?php $isLiked = !empty($song['is_liked']); ?>
                    <div class="compact-song-card song-item"
                         data-id="<?php echo h($song['id']); ?>"
                         data-title="<?php echo h($song['title']); ?>"
                         data-artist="<?php echo h(artistName($song['artist'] ?? '')); ?>"
                         data-album="<?php echo h(albumName($song['album'] ?? '')); ?>"
                         data-duration="<?php echo h($song['duration'] ?? 0); ?>"
                         data-cover-url="<?php echo h(coverUrl($song)); ?>"
                         data-liked="<?php echo $isLiked ? '1' : '0'; ?>">
                        <button type="button" class="play-row-btn compact-play" title="재생"><i class="fas fa-play"></i></button>
                        <img src="<?php echo h(coverUrl($song)); ?>" alt="" class="compact-cover" loading="lazy" width="52" height="52" onerror="this.onerror=null;this.src='assets/img/default-cover.svg';">
                        <div class="compact-song-meta">
                            <a href="song.php?id=<?php echo h($song['id']); ?>" class="compact-title"><?php echo h($song['title']); ?></a>
                            <span><?php echo h(artistName($song['artist'] ?? '')); ?></span>
                            <?php if (!empty($song['recommend_reason'])): ?><small class="recommend-reason"><?php echo h($song['recommend_reason']); ?></small><?php endif; ?>
                        </div>
                        <div class="compact-song-actions" aria-label="곡 작업">
                            <button type="button" class="like-btn <?php echo $isLiked ? 'liked' : ''; ?>" title="<?php echo $isLiked ? '좋아요 취소' : '좋아요'; ?>" aria-label="<?php echo $isLiked ? '좋아요 취소' : '좋아요'; ?>">
                                <i class="<?php echo $isLiked ? 'fas fa-heart' : 'far fa-heart'; ?>"></i>
                            </button>
                            <button type="button" class="add-next-btn" title="다음에 재생" aria-label="다음에 재생"><i class="fas fa-forward-step"></i></button>
                            <button type="button" class="add-playlist-btn" title="플레이리스트에 추가" aria-label="플레이리스트에 추가"><i class="fas fa-plus"></i></button>
                            <button type="button"
                                    class="metadata-request-btn"
                                    data-no-pjax
                                    data-song-id="<?php echo h($song['id']); ?>"
                                    data-song-title="<?php echo h($song['title']); ?>"
                                    data-song-artist="<?php echo h(artistName($song['artist'] ?? '')); ?>"
                                    data-song-album="<?php echo h(albumName($song['album'] ?? '')); ?>"
                                    title="정보 수정 요청"
                                    aria-label="정보 수정 요청">
                                <i class="fa-solid fa-pen-to-square"></i>
                            </button>
                        </div>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>
    </section>
    <?php
}

function renderSongList($songs, $options = []) {
    $opts = array_merge([
        'show_like' => true,
        'show_playlist_add' => true,
        'show_download' => function_exists('hasPermission') ? hasPermission('music_manage') : false,
        'show_remove' => false,
        'show_recent_remove' => false,
        'show_rank' => false,
        'playlist_id' => null,
        'show_play_actions' => true,
        'show_metadata_request' => null,
    ], $options);
    if ($opts['show_metadata_request'] === null) {
        $opts['show_metadata_request'] = $opts['show_like'] || $opts['show_playlist_add'];
    }
    $showActions = $opts['show_like'] || $opts['show_playlist_add'] || $opts['show_download'] || $opts['show_remove'] || $opts['show_recent_remove'] || $opts['show_metadata_request'];
    $basePath = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') !== false ? '../' : '';
    $colspan = 9 + ($opts['show_rank'] ? 1 : 0) + ($showActions ? 1 : 0);
    ?>
    <?php if ($opts['show_play_actions'] && !empty($songs)) renderPageActions(); ?>
    <div class="table-container song-list-container">
        <table class="data-table song-table">
            <thead>
                <tr>
                    <?php if ($opts['show_rank']): ?><th class="text-center">순위</th><?php endif; ?>
                    <th class="text-center">재생</th>
                    <th>곡명</th>
                    <th>아티스트</th>
                    <th>앨범</th>
                    <th>장르</th>
                    <th class="text-center">시간</th>
                    <th class="text-center">형식</th>
                    <th class="text-center">재생 수</th>
                    <?php if ($showActions): ?><th class="text-center">관리</th><?php endif; ?>
                </tr>
            </thead>
            <tbody>
                <?php if (empty($songs)): ?>
                    <tr>
                        <td colspan="<?php echo $colspan; ?>" class="text-center empty-list-cell">
                            <div class="empty-list-state">
                                <i class="fa-solid fa-compact-disc"></i>
                                <p>표시할 음악이 없습니다.</p>
                            </div>
                        </td>
                    </tr>
                <?php else: ?>
                    <?php $rank = 1; foreach ($songs as $song): ?>
                        <?php $isLiked = !empty($song['is_liked']); ?>
                        <tr class="song-item"
                            data-id="<?php echo h($song['id']); ?>"
                            data-title="<?php echo h($song['title']); ?>"
                            data-artist="<?php echo h(artistName($song['artist'] ?? '')); ?>"
                            data-album="<?php echo h(albumName($song['album'] ?? '')); ?>"
                            data-duration="<?php echo h($song['duration'] ?? 0); ?>"
                            data-cover-url="<?php echo h(coverUrl($song)); ?>"
                            data-liked="<?php echo $isLiked ? '1' : '0'; ?>">
                            <?php if ($opts['show_rank']): ?><td class="text-center rank-cell"><?php echo $rank++; ?></td><?php endif; ?>
                            <td class="text-center"><button type="button" class="play-row-btn" title="재생"><i class="fas fa-play"></i></button></td>
                            <td class="song-title-cell" title="<?php echo h($song['title']); ?>">
                                <div class="song-title-wrap">
                                    <img src="<?php echo h(coverUrl($song)); ?>" alt="" class="list-cover" loading="lazy" width="48" height="48" onerror="this.onerror=null;this.src='assets/img/default-cover.svg';">
                                    <div class="song-title-stack">
                                        <a href="song.php?id=<?php echo h($song['id']); ?>" class="song-title-link" title="<?php echo h($song['title']); ?>"><?php echo h($song['title']); ?></a>
                                        <div class="song-list-badges">
                                            <?php if (!empty($song['user_rating'])): ?><span class="song-rating-badge"><i class="fas fa-star"></i> <?php echo h((int)$song['user_rating']); ?></span><?php endif; ?>
                                            <?php if (!empty($song['has_note'])): ?><span class="song-note-badge"><i class="fa-regular fa-note-sticky"></i> 메모</span><?php endif; ?>
                                            <?php if (!empty($song['recommend_reason'])): ?><span class="song-note-badge recommend-reason-badge"><i class="fa-solid fa-lightbulb"></i> <?php echo h($song['recommend_reason']); ?></span><?php endif; ?>
                                        </div>
                                    </div>
                                </div>
                            </td>
                            <?php $artistLabelForLink = artistName($song['artist'] ?? ''); ?>
                            <td class="artist-cell" title="<?php echo h($artistLabelForLink); ?>">
                                <a class="song-meta-link artist-meta-link" href="artist.php?name=<?php echo rawurlencode($artistLabelForLink); ?>"><?php echo h($artistLabelForLink); ?></a>
                            </td>
                            <?php $albumLabelForLink = albumName($song['album'] ?? ''); ?>
                            <td class="album-cell" title="<?php echo h($albumLabelForLink); ?>">
                                <a class="song-meta-link album-meta-link" href="album.php?album=<?php echo rawurlencode($albumLabelForLink); ?>&artist=<?php echo rawurlencode($artistLabelForLink); ?>"><?php echo h($albumLabelForLink); ?></a>
                            </td>
                            <?php $genreLabelForLink = genreName($song['genre'] ?? ''); ?>
                            <td class="genre-cell" title="<?php echo h($genreLabelForLink); ?>">
                                <a class="song-meta-link genre-meta-link" href="genre.php?name=<?php echo rawurlencode($genreLabelForLink); ?>"><?php echo h($genreLabelForLink); ?></a>
                            </td>
                            <td class="text-center duration-cell"><?php echo h(formatDuration($song['duration'] ?? null)); ?></td>
                            <td class="text-center format-cell"><span class="file-ext-badge"><?php echo h(strtoupper($song['extension'] ?? '-')); ?></span></td>
                            <td class="text-center play-count-td"><?php echo number_format((int)($song['play_count'] ?? 0)); ?></td>
                            <?php if ($showActions): ?>
                                <td class="text-center actions-cell">
                                    <div class="song-action-group">
                                        <?php if ($opts['show_like']): ?>
                                            <button type="button" class="like-btn <?php echo $isLiked ? 'liked' : ''; ?>" title="<?php echo $isLiked ? '좋아요 취소' : '좋아요'; ?>">
                                                <i class="<?php echo $isLiked ? 'fas fa-heart' : 'far fa-heart'; ?>"></i>
                                            </button>
                                        <?php endif; ?>
                                        <?php if ($opts['show_playlist_add']): ?>
                                            <button type="button" class="add-playlist-btn" title="플레이리스트에 추가"><i class="fas fa-plus"></i></button>
                                        <?php endif; ?>
                                        <?php if ($opts['show_download']): ?>
                                            <a href="<?php echo h($basePath . 'download.php?id=' . rawurlencode((string)$song['id'])); ?>" class="download-song-btn" title="음원 다운로드" aria-label="음원 다운로드" data-no-pjax download>
                                                <i class="fas fa-download"></i>
                                            </a>
                                        <?php endif; ?>
                                        <?php if ($opts['show_metadata_request']): ?>
                                            <button type="button"
                                                    class="metadata-request-btn"
                                                    data-no-pjax
                                                    data-song-id="<?php echo h($song['id']); ?>"
                                                    data-song-title="<?php echo h($song['title']); ?>"
                                                    data-song-artist="<?php echo h(artistName($song['artist'] ?? '')); ?>"
                                                    data-song-album="<?php echo h(albumName($song['album'] ?? '')); ?>"
                                                    title="정보 수정 요청"
                                                    aria-label="정보 수정 요청">
                                                <i class="fa-solid fa-pen-to-square"></i>
                                            </button>
                                        <?php endif; ?>
                                        <?php if ($opts['show_remove'] && $opts['playlist_id'] !== null): ?>
                                            <button type="button" class="remove-playlist-song-btn btn-danger" data-playlist-id="<?php echo h($opts['playlist_id']); ?>" title="플레이리스트에서 제거">
                                                <i class="fas fa-trash-alt"></i>
                                            </button>
                                        <?php endif; ?>
                                        <?php if ($opts['show_recent_remove']): ?>
                                            <button type="button"
                                                    class="recent-remove-btn"
                                                    data-song-id="<?php echo h($song['id']); ?>"
                                                    title="최근 재생 기록에서 삭제"
                                                    aria-label="최근 재생 기록에서 삭제">
                                                <i class="fa-solid fa-xmark"></i>
                                            </button>
                                        <?php endif; ?>
                                    </div>
                                </td>
                            <?php endif; ?>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
            </tbody>
        </table>
    </div>
    <?php
}
