import axios from 'axios';
import { set } from 'idb-keyval';
import orderBy from 'lodash/orderBy';

import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import {
    albumArtistListSortMap,
    albumListSortMap,
    AuthenticationResponse,
    DeleteArtistImageArgs,
    DeleteArtistImageResponse,
    DeleteInternetRadioStationImageArgs,
    DeleteInternetRadioStationImageResponse,
    DeletePlaylistImageArgs,
    DeletePlaylistImageResponse,
    genreListSortMap,
    InternalControllerEndpoint,
    playlistListSortMap,
    PlaylistSongListArgs,
    PlaylistSongListResponse,
    ServerListItemWithCredential,
    SongListSort,
    songListSortMap,
    SortOrder,
    sortOrderMap,
    tagListSortMap,
    UploadArtistImageArgs,
    UploadArtistImageResponse,
    UploadInternetRadioStationImageArgs,
    UploadInternetRadioStationImageResponse,
    UploadPlaylistImageArgs,
    UploadPlaylistImageResponse,
    userListSortMap,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';

const VERSION_INFO: VersionInfo = [
    // Why 2? Subsonic controller will return 1 for its own implementation
    // Use 2 to denote that Navidrome's own API has a different endpoint
    [
        '0.61.0',
        {
            [ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
            [ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
            [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
        },
    ],
    ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
    ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
    ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
    ['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
    ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
    ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];

const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
    { label: 'all artists', value: '' },
    'arranger',
    'artist',
    'composer',
    'conductor',
    'director',
    'djmixer',
    'engineer',
    'lyricist',
    'mixer',
    'performer',
    'producer',
    'remixer',
];

// Tags that are irrelevant or non-functional as filters
const EXCLUDED_TAGS = new Set<string>([
    'genre', // Duplicate of genre filter
]);

const EXCLUDED_ALBUM_TAGS = new Set<string>([
    'asin',
    'barcode',
    'copyright',
    'encodedby',
    'isrc',
    'key',
    'language',
    'musicbrainz_workid',
    'script',
    'website',
    'work',
]);

const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);

// Defining a re-usable Collator instance for performance reasons.
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
const collator = new Intl.Collator();

// Tags that use IDs as values as opposed to the tag value
const ID_TAGS = new Set<string>(['albumversion', 'mood']);

const excludeMissing = (server?: null | ServerListItemWithCredential) => {
    if (!server) {
        return undefined;
    }

    if (hasFeature(server, ServerFeature.BFR)) {
        return { missing: false };
    }

    return undefined;
};

const getLibraryId = (musicFolderId?: string | string[]): string[] | undefined => {
    if (!musicFolderId) {
        return undefined;
    }

    return Array.isArray(musicFolderId) ? musicFolderId : [musicFolderId];
};

const getArtistSongKey = (server: null | ServerListItemWithCredential) =>
    hasFeature(server, ServerFeature.TRACK_ALBUM_ARTIST_SEARCH) ? 'artists_id' : 'album_artist_id';

export const NavidromeController: InternalControllerEndpoint = {
    addToPlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        const res = await ndApiClient(apiClientProps).addToPlaylist({
            body: {
                ids: body.songId,
            },
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to add to playlist');
        }

        return null;
    },
    authenticate: async (url, body): Promise<AuthenticationResponse> => {
        const cleanServerUrl = url.replace(/\/$/, '');

        const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
            body: {
                password: body.password,
                username: body.username,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to authenticate');
        }

        return {
            credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
            isAdmin: Boolean(res.body.data.isAdmin),
            ndCredential: res.body.data.token,
            userId: res.body.data.id,
            username: res.body.data.username,
        };
    },
    createFavorite: SubsonicController.createFavorite,
    createInternetRadioStation: SubsonicController.createInternetRadioStation,
    createPlaylist: async (args) => {
        const { apiClientProps, body } = args;

        const res = await ndApiClient(apiClientProps).createPlaylist({
            body: {
                comment: body.comment,
                name: body.name,
                ownerId: body.ownerId,
                public: body.public,
                rules: body.queryBuilderRules,
                sync: body.sync,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to create playlist');
        }

        return {
            id: res.body.data.id,
        };
    },
    deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to delete artist image');
        }

        return res.body.data.status === 'ok';
    },
    deleteFavorite: SubsonicController.deleteFavorite,
    deleteInternetRadioStation: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to delete internet radio station');
        }

        return null;
    },
    deleteInternetRadioStationImage: async (
        args: DeleteInternetRadioStationImageArgs,
    ): Promise<DeleteInternetRadioStationImageResponse> => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to delete internet radio station image');
        }

        return res.body.data.status === 'ok';
    },
    deletePlaylist: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).deletePlaylist({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to delete playlist');
        }

        return null;
    },
    deletePlaylistImage: async (
        args: DeletePlaylistImageArgs,
    ): Promise<DeletePlaylistImageResponse> => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to delete playlist image');
        }

        return res.body.data.status === 'ok';
    },
    getAlbumArtistDetail: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album artist detail');
        }

        if (!apiClientProps.serverId) {
            throw new Error('Server is required');
        }

        return ndNormalize.albumArtist(res.body.data, apiClientProps.server);
    },
    getAlbumArtistInfo: async (args) => {
        const { apiClientProps, query } = args;

        const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
            query: {
                id: query.id,
                ...(query.limit != null && { count: query.limit }),
            },
        });

        if (artistInfoRes.status !== 200) {
            return null;
        }

        const artistInfo = artistInfoRes.body.artistInfo;
        const imageUrl =
            artistInfo?.largeImageUrl ||
            artistInfo?.mediumImageUrl ||
            artistInfo?.smallImageUrl ||
            null;

        return {
            biography: artistInfo?.biography || null,
            imageUrl,
            similarArtists:
                artistInfo?.similarArtist?.map((artist) => ({
                    id: artist.id,
                    imageId: artist.id,
                    imageUrl: null,
                    name: artist.name,
                    userFavorite: Boolean(artist.starred) || false,
                    userRating: artist.userRating ?? null,
                })) ?? null,
        };
    },
    getAlbumArtistList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getAlbumArtistList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: albumArtistListSortMap.navidrome[query.sortBy],
                _start: query.startIndex,
                library_id: getLibraryId(query.musicFolderId),
                name: query.searchTerm,
                starred: query.favorite,
                ...query._custom,
                role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '',
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album artist list');
        }

        return {
            items: res.body.data.map((albumArtist) =>
                // Navidrome native API will return only external URL small/medium/large
                // image URL. Set large image to undefined to force `albumArtist` to use
                // /rest/getCoverArt.view?id=ar-...
                ndNormalize.albumArtist(
                    {
                        ...albumArtist,
                        largeImageUrl: undefined,
                    },
                    apiClientProps.server,
                ),
            ),
            startIndex: query.startIndex,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getAlbumArtistListCount: async ({ apiClientProps, query }) =>
        NavidromeController.getAlbumArtistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getAlbumDetail: async (args) => {
        const { apiClientProps, context, query } = args;

        const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
            params: {
                id: query.id,
            },
        });

        const songsData = await ndApiClient(apiClientProps).getSongList({
            query: {
                _end: 0,
                _order: 'ASC',
                _sort: NDSongListSort.ALBUM,
                _start: 0,
                album_id: [query.id],
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (albumRes.status !== 200 || songsData.status !== 200) {
            throw new Error('Failed to get album detail');
        }

        return ndNormalize.album(
            { ...albumRes.body.data, songs: songsData.body.data },
            apiClientProps.server,
            context?.pathReplace,
            context?.pathReplaceWith,
        );
    },
    getAlbumInfo: async (args) => {
        const { apiClientProps, query } = args;

        const albumInfo = await ssApiClient(apiClientProps).getAlbumInfo2({
            query: {
                id: query.id,
            },
        });

        if (albumInfo.status !== 200) {
            throw new Error('Failed to get album info');
        }

        const info = albumInfo.body.albumInfo;

        return {
            imageUrl: info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null,
            notes: info.notes || null,
        };
    },
    getAlbumList: async (args) => {
        const { apiClientProps, context, query } = args;

        const genres = hasFeature(apiClientProps.server, ServerFeature.BFR)
            ? query.genreIds
            : query.genreIds?.[0];

        const artistIds = hasFeature(apiClientProps.server, ServerFeature.BFR)
            ? query.artistIds
            : query.artistIds?.[0];

        const res = await ndApiClient(apiClientProps).getAlbumList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: albumListSortMap.navidrome[query.sortBy],
                _start: query.startIndex,
                artist_id: artistIds,
                compilation: query.compilation,
                genre_id: genres,
                has_rating: query.hasRating,
                library_id: getLibraryId(query.musicFolderId),
                name: query.searchTerm,
                recently_played: query.isRecentlyPlayed,
                starred: query.favorite,
                year: query.maxYear || query.minYear,
                ...query._custom,
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album list');
        }

        return {
            items: res.body.data.map((album) =>
                ndNormalize.album(
                    album,
                    apiClientProps.server,
                    context?.pathReplace,
                    context?.pathReplaceWith,
                ),
            ),
            startIndex: query?.startIndex || 0,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getAlbumListCount: async ({ apiClientProps, query }) =>
        NavidromeController.getAlbumList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getAlbumRadio: async (args) => {
        const { apiClientProps, query } = args;

        // Use getSimilarSongs API for album radio
        const res = await ssApiClient({
            ...apiClientProps,
            silent: true,
        }).getSimilarSongs({
            query: {
                count: query.count,
                id: query.albumId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album radio songs');
        }

        if (!res.body.similarSongs?.song) {
            return [];
        }

        return res.body.similarSongs.song.map((song) =>
            ssNormalize.song(
                song,
                apiClientProps.server,
                args.context?.pathReplace,
                args.context?.pathReplaceWith,
            ),
        );
    },
    getArtistList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getAlbumArtistList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: albumArtistListSortMap.navidrome[query.sortBy],
                _start: query.startIndex,
                library_id: getLibraryId(query.musicFolderId),
                name: query.searchTerm,
                role: query.role || undefined,
                starred: query.favorite,
                ...query._custom,
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get artist list');
        }

        return {
            items: res.body.data.map((albumArtist) =>
                // Navidrome native API will return only external URL small/medium/large
                // image URL. Set large image to undefined to force `albumArtist` to use
                // /rest/getCoverArt.view?id=ar-...
                ndNormalize.albumArtist(
                    {
                        ...albumArtist,
                        largeImageUrl: undefined,
                    },
                    apiClientProps.server,
                ),
            ),
            startIndex: query.startIndex,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getArtistListCount: async ({ apiClientProps, query }) =>
        NavidromeController.getArtistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getArtistRadio: async (args) => {
        const { apiClientProps, query } = args;

        // Use getSimilarSongs2 API for artist radio
        const res = await ssApiClient({
            ...apiClientProps,
            silent: true,
        }).getSimilarSongs2({
            query: {
                count: query.count,
                id: query.artistId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get artist radio songs');
        }

        if (!res.body.similarSongs2?.song) {
            return [];
        }

        return res.body.similarSongs2.song.map((song) =>
            ssNormalize.song(
                song,
                apiClientProps.server,
                args.context?.pathReplace,
                args.context?.pathReplaceWith,
            ),
        );
    },
    getDownloadUrl: SubsonicController.getDownloadUrl,
    getFolder: SubsonicController.getFolder,
    getGenreList: async (args) => {
        const { apiClientProps, query } = args;

        if (hasFeature(apiClientProps.server, ServerFeature.BFR)) {
            const res = await ndApiClient(apiClientProps).getTagList({
                query: {
                    _end: query.startIndex + (query.limit || 0),
                    _order: sortOrderMap.navidrome[query.sortOrder],
                    _sort: tagListSortMap.navidrome[query.sortBy],
                    _start: query.startIndex,
                    library_id: getLibraryId(query.musicFolderId),
                    tag_name: 'genre',
                    tag_value: query.searchTerm,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get genre list');
            }

            return {
                items: res.body.data.map((genre) =>
                    ndNormalize.genre(
                        {
                            albumCount: genre.albumCount,
                            id: genre.id,
                            name: genre.tagValue,
                            songCount: genre.songCount,
                        },
                        apiClientProps.server,
                    ),
                ),
                startIndex: query.startIndex || 0,
                totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
            };
        }

        const res = await ndApiClient(apiClientProps).getGenreList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: genreListSortMap.navidrome[query.sortBy],
                _start: query.startIndex,
                library_id: getLibraryId(query.musicFolderId),
                name: query.searchTerm,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get genre list');
        }

        return {
            items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)),
            startIndex: query.startIndex || 0,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getImageRequest: SubsonicController.getImageRequest,
    getImageUrl: SubsonicController.getImageUrl,
    getInternetRadioStations: async (args) => {
        const { apiClientProps } = args;

        const res = await ndApiClient(apiClientProps).getRadioList({
            query: {
                _end: -1,
                _order: 'ASC',
                _sort: NDRadioListSort.NAME,
                _start: 0,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get internet radio stations');
        }

        return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
    },
    getLyrics: SubsonicController.getLyrics,
    getMusicFolderList: SubsonicController.getMusicFolderList,
    getPlaylistDetail: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getPlaylistDetail({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist detail');
        }

        return ndNormalize.playlist(res.body.data, apiClientProps.server);
    },
    getPlaylistList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getPlaylistList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
                _start: query.startIndex,
                q: query.searchTerm,
                smart: query.excludeSmartPlaylists ? false : undefined,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist list');
        }

        return {
            items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
            startIndex: query?.startIndex || 0,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getPlaylistListCount: async ({ apiClientProps, query }) =>
        NavidromeController.getPlaylistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps as any).getPlaylistSongList({
            params: {
                id: query.id,
            },
            query: {
                _end: -1,
                _order: 'ASC',
                _sort: NDSongListSort.ID,
                _start: 0,
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist song list');
        }

        return {
            items: res.body.data.map((item) =>
                ndNormalize.song(
                    item,
                    apiClientProps.server,
                    args.context?.pathReplace,
                    args.context?.pathReplaceWith,
                ),
            ),
            startIndex: 0,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    getPlayQueue: async (args) => {
        const { apiClientProps } = args;

        if (hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2)) {
            const res = await ndApiClient(apiClientProps).getQueue();

            if (res.status !== 200) {
                throw new Error('Failed to get play queue');
            }

            const { changedBy, current, items = [], position, updatedAt } = res.body.data; // if there is no queue saved, items is undefined

            const entries = items.map((song) =>
                ndNormalize.song(
                    song,
                    apiClientProps.server,
                    args.context?.pathReplace,
                    args.context?.pathReplaceWith,
                ),
            );

            return {
                changed: updatedAt,
                changedBy,
                currentIndex: current !== undefined ? current : 0,
                entry: entries,
                positionMs: position,
                username: apiClientProps.server?.username ?? '',
            };
        }

        return SubsonicController.getPlayQueue(args);
    },
    getRandomSongList: SubsonicController.getRandomSongList,
    getRoles: async ({ apiClientProps }) =>
        hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
    getServerInfo: async (args) => {
        const { apiClientProps } = args;

        // Navidrome will always populate serverVersion
        const ping = await ssApiClient(apiClientProps).ping();

        if (ping.status !== 200) {
            throw new Error('Failed to ping server');
        }

        if (ping.body.serverVersion?.includes('pr-2709')) {
            ping.body.serverVersion = '0.55.0';
        }

        const navidromeFeatures = getFeatures(VERSION_INFO, ping.body.serverVersion!);
        const subsonicArgs = await SubsonicController.getServerInfo(args);

        const features = {
            ...subsonicArgs.features,
            ...navidromeFeatures,
            publicPlaylist: [1],
            [ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1],
            [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
        };

        if (subsonicArgs.features.serverPlayQueue && navidromeFeatures.serverPlayQueue) {
            features.serverPlayQueue = navidromeFeatures.serverPlayQueue.concat(
                subsonicArgs.features.serverPlayQueue,
            );
        }

        return {
            features,
            id: apiClientProps.serverId,
            version: ping.body.serverVersion!,
        };
    },
    getSimilarSongs: async (args) => {
        const { apiClientProps, query } = args;

        // Prefer getSimilarSongs (which queries last.fm) where available
        // otherwise find other tracks by the same album artist
        const res = await ssApiClient({
            ...apiClientProps,
            silent: true,
        }).getSimilarSongs({
            query: {
                count: query.count,
                id: query.songId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get similar songs');
        }

        return (
            (res.body.similarSongs?.song || [])
                .filter((song) => song.id !== query.songId)
                .map((song) =>
                    ssNormalize.song(
                        song,
                        apiClientProps.server,
                        args.context?.pathReplace,
                        args.context?.pathReplaceWith,
                    ),
                ) || []
        );
    },
    getSongDetail: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getSongDetail({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get song detail');
        }

        return ndNormalize.song(
            res.body.data,
            apiClientProps.server,
            args.context?.pathReplace,
            args.context?.pathReplaceWith,
        );
    },
    getSongList: async (args) => {
        const { apiClientProps, query } = args;

        const ALBUM_IDS_BATCH_SIZE = 500;
        const albumIds = query.albumIds;
        const shouldBatch = albumIds && albumIds.length > ALBUM_IDS_BATCH_SIZE;

        const fetchAlbums = async (albumIdBatch: string[] | undefined) => {
            const res = await ndApiClient(apiClientProps).getSongList({
                query: {
                    _end: query.startIndex + (query.limit || -1),
                    _order: sortOrderMap.navidrome[query.sortOrder],
                    _sort: songListSortMap.navidrome[query.sortBy],
                    _start: query.startIndex,
                    album_id: albumIdBatch ?? query.albumIds,
                    genre_id: query.genreIds,
                    [getArtistSongKey(apiClientProps.server)]:
                        query.artistIds ?? query.albumArtistIds,
                    ...(hasFeature(
                        apiClientProps.server,
                        ServerFeature.TRACK_YES_NO_RATING_FILTER,
                    ) && query.hasRating !== undefined
                        ? { has_rating: query.hasRating }
                        : {}),
                    library_id: getLibraryId(query.musicFolderId),
                    starred: query.favorite,
                    title: query.searchTerm,
                    year: query.maxYear || query.minYear,
                    ...query._custom,
                    ...excludeMissing(apiClientProps.server),
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get song list');
            }

            return {
                items: res.body.data.map((song) =>
                    ndNormalize.song(
                        song,
                        apiClientProps.server,
                        args.context?.pathReplace,
                        args.context?.pathReplaceWith,
                    ),
                ),
                totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
            };
        };

        if (shouldBatch && albumIds) {
            const batches: string[][] = [];
            for (let i = 0; i < albumIds.length; i += ALBUM_IDS_BATCH_SIZE) {
                batches.push(albumIds.slice(i, i + ALBUM_IDS_BATCH_SIZE));
            }

            const results = await Promise.all(batches.map((batch) => fetchAlbums(batch)));

            return {
                items: results.flatMap((r) => r.items),
                startIndex: query?.startIndex ?? 0,
                totalRecordCount: results.reduce((sum, r) => sum + r.totalRecordCount, 0),
            };
        }

        const albums = await fetchAlbums(undefined);

        return {
            items: albums.items,
            startIndex: query?.startIndex ?? 0,
            totalRecordCount: albums.totalRecordCount,
        };
    },

    getSongListCount: async ({ apiClientProps, query }) =>
        NavidromeController.getSongList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getStreamUrl: SubsonicController.getStreamUrl,
    getStructuredLyrics: SubsonicController.getStructuredLyrics,
    getTagList: async (args) => {
        const { apiClientProps } = args;

        if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
            return { excluded: { album: [], song: [] } };
        }

        const res = await ndApiClient(apiClientProps).getTagList({
            query: {},
        });

        if (res.status !== 200) {
            throw new Error('failed to get tags');
        }

        const tagsToValues = new Map<string, { id: string; name: string }[]>();

        for (const tag of res.body.data) {
            if (!EXCLUDED_TAGS.has(tag.tagName)) {
                if (tagsToValues.has(tag.tagName)) {
                    tagsToValues.get(tag.tagName)!.push({
                        id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
                        name: tag.tagValue,
                    });
                } else {
                    tagsToValues.set(tag.tagName, [
                        {
                            id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
                            name: tag.tagValue,
                        },
                    ]);
                }
            }
        }

        const tags = Array.from(tagsToValues)
            .map((data) => ({
                name: data[0],
                options: data[1]
                    .sort((a, b) => {
                        return numericSortCollator.compare(
                            a.name.toLocaleLowerCase(),
                            b.name.toLocaleLowerCase(),
                        );
                    })
                    .map((option) => ({ id: option.id, name: option.name })),
            }))
            .sort((a, b) =>
                collator.compare(a.name.toLocaleLowerCase(), b.name.toLocaleLowerCase()),
            );

        const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values());
        const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());

        return {
            excluded: {
                album: excludedAlbumTags,
                song: excludedSongTags,
            },
            tags,
        };
    },
    getTopSongs: async (args) => {
        const { apiClientProps, query } = args;

        const type = query.type === 'personal' ? 'personal' : 'community';

        if (type === 'community') {
            const res = await ssApiClient(apiClientProps).getTopSongsList({
                query: {
                    artist: query.artist,
                    count: query.limit,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get top songs');
            }

            return {
                items: (res.body.topSongs?.song || []).map((song) =>
                    ssNormalize.song(
                        song,
                        apiClientProps.server,
                        args.context?.pathReplace,
                        args.context?.pathReplaceWith,
                    ),
                ),
                startIndex: 0,
                totalRecordCount: res.body.topSongs?.song?.length || 0,
            };
        }

        const res = await NavidromeController.getSongList({
            apiClientProps,
            context: args.context,
            query: {
                artistIds: [query.artistId],
                sortBy: SongListSort.PLAY_COUNT,
                sortOrder: SortOrder.DESC,
                startIndex: 0,
            },
        });

        const songsWithPlayCount = orderBy(
            res.items.filter((song) => song.playCount > 0),
            ['playCount', 'albumId', 'trackNumber'],
            ['desc', 'asc', 'asc'],
        );

        return {
            items: songsWithPlayCount,
            startIndex: 0,
            totalRecordCount: res.totalRecordCount,
        };
    },
    getUserInfo: SubsonicController.getUserInfo,
    getUserList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).getUserList({
            query: {
                _end: query.startIndex + (query.limit || 0),
                _order: sortOrderMap.navidrome[query.sortOrder],
                _sort: userListSortMap.navidrome[query.sortBy],
                _start: query.startIndex,
                ...query._custom,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get user list');
        }

        return {
            items: res.body.data.map((user) => ndNormalize.user(user)),
            startIndex: query?.startIndex || 0,
            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
        };
    },
    movePlaylistItem: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).movePlaylistItem({
            body: {
                insert_before: (query.endingIndex + 1).toString(),
            },
            params: {
                playlistId: query.playlistId,
                trackNumber: query.startingIndex.toString(),
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to move item in playlist');
        }
    },
    removeFromPlaylist: async (args) => {
        const { apiClientProps, query } = args;

        const res = await ndApiClient(apiClientProps).removeFromPlaylist({
            params: {
                id: query.id,
            },
            query: {
                id: query.songId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to remove from playlist');
        }

        return null;
    },
    replacePlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        // 1. Fetch existing songs from the playlist without any sorts
        const existingSongsRes = await ndApiClient(apiClientProps as any).getPlaylistSongList({
            params: {
                id: query.id,
            },
            query: {
                _end: -1,
                _order: 'ASC',
                _sort: NDSongListSort.ID,
                _start: 0,
                ...excludeMissing(apiClientProps.server),
            },
        });

        if (existingSongsRes.status !== 200) {
            throw new Error('Failed to fetch existing playlist songs');
        }

        const existingSongs = existingSongsRes.body.data.map((item) =>
            ndNormalize.song(
                item,
                apiClientProps.server,
                args.context?.pathReplace,
                args.context?.pathReplaceWith,
            ),
        );

        // 2. Get playlist detail to get the name
        const playlistDetailRes = await ndApiClient(apiClientProps).getPlaylistDetail({
            params: {
                id: query.id,
            },
        });

        if (playlistDetailRes.status !== 200) {
            throw new Error('Failed to get playlist detail');
        }

        const playlist = ndNormalize.playlist(playlistDetailRes.body.data, apiClientProps.server);

        // 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
        const backup = {
            id: query.id,
            name: playlist.name,
            songIds: existingSongs.map((song) => song.id),
            timestamp: Date.now(),
        };

        // Store backup in IndexedDB using idb-keyval
        const backupKey = `playlist-backup-${query.id}`;
        await set(backupKey, backup);

        // 4. Remove all songs from the playlist
        if (existingSongs.length > 0) {
            const existingPlaylistItemIds = existingSongs
                .map((song) => song.playlistItemId)
                .filter((id): id is string => id !== undefined && id !== null);

            if (existingPlaylistItemIds.length > 0) {
                const removeRes = await ndApiClient(apiClientProps).removeFromPlaylist({
                    params: {
                        id: query.id,
                    },
                    query: {
                        id: existingPlaylistItemIds,
                    },
                });

                if (removeRes.status !== 200) {
                    throw new Error('Failed to remove songs from playlist');
                }
            }
        }

        // 5. Add the new song ids to the playlist
        if (body.songId.length > 0) {
            const addRes = await ndApiClient(apiClientProps).addToPlaylist({
                body: {
                    ids: body.songId,
                },
                params: {
                    id: query.id,
                },
            });

            if (addRes.status !== 200) {
                throw new Error('Failed to add songs to playlist');
            }
        }

        return null;
    },
    savePlayQueue: async (args) => {
        const { apiClientProps, query } = args;

        // Prefer using Navidrome's API only in the situation where the OpenSubsonic extension is not present
        // OpenSubsonic extension is preferable as the credentials never expire
        if (
            hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2) &&
            !hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 1)
        ) {
            const res = await ndApiClient(apiClientProps).saveQueue({
                body: {
                    current: query.currentIndex !== undefined ? query.currentIndex : undefined,
                    ids: query.songs,
                    position: query.positionMs,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to save play queue');
            }
            return;
        }

        return SubsonicController.savePlayQueue(args);
    },
    scrobble: SubsonicController.scrobble,
    search: SubsonicController.search,
    setPlaylistSongs: SubsonicController.setPlaylistSongs,
    setRating: SubsonicController.setRating,
    shareItem: async (args) => {
        const { apiClientProps, body } = args;

        const res = await ndApiClient(apiClientProps).shareItem({
            body: {
                description: body.description,
                downloadable: body.downloadable,
                expires: body.expires,
                resourceIds: body.resourceIds,
                resourceType: body.resourceType,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to share item');
        }

        return {
            id: res.body.data.id,
        };
    },
    updateInternetRadioStation: async (args) => {
        const { apiClientProps, body, query } = args;

        const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
            body: {
                homePageUrl: body.homepageUrl ?? '',
                name: body.name,
                streamUrl: body.streamUrl,
            },
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to update internet radio station');
        }

        return null;
    },
    updatePlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        const res = await ndApiClient(apiClientProps).updatePlaylist({
            body: {
                comment: body.comment || '',
                name: body.name,
                ownerId: body.ownerId,
                public: body?.public || false,
                rules: body.queryBuilderRules,
                sync: body.sync,
                ...body._custom,
            },
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to update playlist');
        }

        return null;
    },
    uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
        const { apiClientProps, body, query } = args;

        const server = apiClientProps.server;
        const serverUrl = server?.url?.replace(/\/$/, '');

        if (!serverUrl) {
            throw new Error('Server is required');
        }

        const form = new FormData();
        const bytes = body.image as Uint8Array<ArrayBuffer>;
        const fileLike =
            typeof File !== 'undefined'
                ? new File([bytes], 'image', { type: 'application/octet-stream' })
                : new Blob([bytes], { type: 'application/octet-stream' });
        form.append('image', fileLike as any);

        const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
            headers: {
                'Content-Type': 'multipart/form-data',
                ...(server?.ndCredential && {
                    'x-nd-authorization': `Bearer ${server.ndCredential}`,
                }),
            },
            signal: apiClientProps.signal,
        });

        if (res.status !== 200) {
            throw new Error('Failed to upload artist image');
        }

        return res.data?.status === 'ok';
    },
    uploadInternetRadioStationImage: async (
        args: UploadInternetRadioStationImageArgs,
    ): Promise<UploadInternetRadioStationImageResponse> => {
        const { apiClientProps, body, query } = args;

        const server = apiClientProps.server;
        const serverUrl = server?.url?.replace(/\/$/, '');

        if (!serverUrl) {
            throw new Error('Server is required');
        }

        const form = new FormData();
        const bytes = body.image as Uint8Array<ArrayBuffer>;
        const fileLike =
            typeof File !== 'undefined'
                ? new File([bytes], 'image', { type: 'application/octet-stream' })
                : new Blob([bytes], { type: 'application/octet-stream' });
        form.append('image', fileLike as any);

        const res = await axios.post(`${serverUrl}/api/radio/${query.id}/image`, form, {
            headers: {
                'Content-Type': 'multipart/form-data',
                ...(server?.ndCredential && {
                    'x-nd-authorization': `Bearer ${server.ndCredential}`,
                }),
            },
            signal: apiClientProps.signal,
        });

        if (res.status !== 200) {
            throw new Error('Failed to upload internet radio station image');
        }

        return res.data?.status === 'ok';
    },
    uploadPlaylistImage: async (
        args: UploadPlaylistImageArgs,
    ): Promise<UploadPlaylistImageResponse> => {
        const { apiClientProps, body, query } = args;

        const server = apiClientProps.server;
        const serverUrl = server?.url?.replace(/\/$/, '');

        if (!serverUrl) {
            throw new Error('Server is required');
        }

        const form = new FormData();
        const bytes = body.image as Uint8Array<ArrayBuffer>;
        const fileLike =
            typeof File !== 'undefined'
                ? new File([bytes], 'image', { type: 'application/octet-stream' })
                : new Blob([bytes], { type: 'application/octet-stream' });
        form.append('image', fileLike as any);

        const res = await axios.post(`${serverUrl}/api/playlist/${query.id}/image`, form, {
            headers: {
                'Content-Type': 'multipart/form-data',
                ...(server?.ndCredential && {
                    'x-nd-authorization': `Bearer ${server.ndCredential}`,
                }),
            },
            signal: apiClientProps.signal,
        });

        if (res.status !== 200) {
            throw new Error('Failed to upload playlist image');
        }

        return res.data?.status === 'ok';
    },
};
