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

function getEmbeddedCover($filePath) {
    if (!file_exists($filePath)) return null;

    $getid3Path = __DIR__ . '/getid3/getid3.php';
    if (file_exists($getid3Path)) {
        require_once $getid3Path;
        try {
            $getID3 = new getID3;
            $fileInfo = $getID3->analyze($filePath);
            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($filePath, '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;
}

function fixEncodingIfNeeded($value) {
    if ($value === null) return null;
    if (is_array($value)) $value = firstNonEmptyValue($value);
    if ($value === null) return null;
    $value = trim((string)$value);
    if ($value === '') return null;
    if (function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) {
        $encoding = mb_detect_encoding($value, ['UTF-8', 'CP949', 'EUC-KR', 'ISO-8859-1'], true);
        if ($encoding && $encoding !== 'UTF-8') {
            $converted = @mb_convert_encoding($value, 'UTF-8', $encoding);
            if ($converted !== false) $value = $converted;
        }
    }
    return $value;
}

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

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

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

function normalizeTrackNumber($value) {
    $value = normalizeMetadataValue($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 = normalizeMetadataValue($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 = normalizeMetadataValue($item);
            if ($item !== null) $items[] = $item;
        }
        return empty($items) ? null : implode(', ', array_values(array_unique($items)));
    }
    return normalizeMetadataValue($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;
    $getid3Path = __DIR__ . '/getid3/getid3.php';
    if (file_exists($getid3Path)) {
        try {
            require_once $getid3Path;
            if (!class_exists('getID3')) {
                throw new Exception('getID3 class not found');
            }
            $getID3 = new getID3;
            $fileInfo = $getID3->analyze($filePath);
            $tags = [];
            foreach (['id3v2', 'quicktime', 'vorbiscomment', 'id3v1', 'riff', 'ape', 'asf'] as $tagKey) {
                if (isset($fileInfo['tags'][$tagKey]) && is_array($fileInfo['tags'][$tagKey])) {
                    $tags = array_merge($tags, $fileInfo['tags'][$tagKey]);
                    $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']) ? normalizeMetadataValue((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'] = $e->getMessage();
        }
    } else {
        // getID3 is optional. When it is not installed, keep scanning with filename fallback.
        $metadata['metadata_error'] = null;
    }

    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'] . '; ' : '') . $e->getMessage());
    }

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

function toWinPath($path) {
    if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' && function_exists('mb_convert_encoding')) {
        return mb_convert_encoding($path, 'CP949', 'UTF-8');
    }
    return $path;
}

function toUtf8Path($path) {
    if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' && function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) {
        $detect = mb_detect_encoding($path, ['UTF-8', 'CP949', 'EUC-KR'], true);
        if ($detect !== 'UTF-8') return mb_convert_encoding($path, 'UTF-8', 'CP949');
    }
    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 jsonResponse($payload, $statusCode = 200) {
    if (!headers_sent()) {
        http_response_code($statusCode);
        header('Content-Type: application/json; charset=utf-8');
    }
    $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,"message":"JSON 응답을 생성하지 못했습니다."}';
    }
    echo $json;
}

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);
    $realBase = realpath($baseDir);
    if (!$realPath || !$realBase) return false;
    return strpos(strtolower(str_replace('\\', '/', $realPath)), strtolower(str_replace('\\', '/', $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_array($context)) return $context;
    $blocked = ['password', 'password_hash', 'csrf_token', 'session', 'cookie', 'authorization'];
    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)) $context[$key] = sanitizeLogContext($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';
        $context = sanitizeLogContext($context);
        $contextJson = null;
        if (!empty($context)) {
            $contextJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if ($contextJson === false) $contextJson = json_encode(['message' => 'context encode failed']);
        }
        $pdo = getDBConnection();
        $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' => (string)$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 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) {
    $pdo = getDBConnection();
    $sql = baseSongSelectSql() . " WHERE " . $whereSql . " ORDER BY " . $orderSql;
    if ($limit === null) $limit = getListDefaultLimit();
    if ($limit !== null) $sql .= " LIMIT " . max(1, (int)$limit);
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

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 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>
    </div>
    <?php
}

function renderMiniSongSection($title, $songs, $moreUrl = null) {
    ?>
    <section class="home-section">
        <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): ?>
                    <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-liked="<?php echo !empty($song['is_liked']) ? '1' : '0'; ?>">
                        <button type="button" class="play-row-btn compact-play" title="재생"><i class="fas fa-play"></i></button>
                        <img src="cover.php?id=<?php echo h($song['id']); ?>" 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>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>
    </section>
    <?php
}

function renderSongList($songs, $options = []) {
    $opts = array_merge([
        'show_like' => true,
        'show_playlist_add' => true,
        'show_remove' => false,
        'show_rank' => false,
        'playlist_id' => null,
        'show_play_actions' => true,
    ], $options);
    $colspan = 9 + ($opts['show_rank'] ? 1 : 0) + (($opts['show_like'] || $opts['show_playlist_add'] || $opts['show_remove']) ? 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 ($opts['show_like'] || $opts['show_playlist_add'] || $opts['show_remove']): ?><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-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">
                                <div class="song-title-wrap">
                                    <img src="cover.php?id=<?php echo h($song['id']); ?>" 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"><?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>
                            <td><?php echo h(artistName($song['artist'] ?? '')); ?></td>
                            <td><?php echo h(albumName($song['album'] ?? '')); ?></td>
                            <td><?php echo h(genreName($song['genre'] ?? '')); ?></td>
                            <td class="text-center"><?php echo h(formatDuration($song['duration'] ?? null)); ?></td>
                            <td class="text-center"><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 ($opts['show_like'] || $opts['show_playlist_add'] || $opts['show_remove']): ?>
                                <td class="text-center">
                                    <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_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; ?>
                                    </div>
                                </td>
                            <?php endif; ?>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
            </tbody>
        </table>
    </div>
    <?php
}
