import type { PlaybackInfo } from './container-classes';

type Params = Record<string, string | number | undefined | null>;

class ResponseError extends Error {
    status: number;
    constructor(status: number, message: string) {
        super(message);
        this.status = status;
    }
}

export interface TypedResponse<T> extends Response {
    json(): Promise<T>;
}

export class TidalResponse<T = unknown> extends Response implements TypedResponse<T> {
    declare json: () => Promise<T>;

    constructor(response: Response);
    constructor(body: BodyInit, init?: ResponseInit);
    constructor(body: BodyInit | Response, init?: ResponseInit) {
        if (body instanceof Response) {
            super(body.body, {
                headers: body.headers,
                status: body.status,
                statusText: body.statusText,
            });
        } else {
            super(body, init);
        }
    }
}

export interface VersionedResponse {
    version: string;
}

export interface TidalArtistRef {
    id: number;
    name: string;
    handle: string | null;
    type: string;
    picture: string | null;
}

export interface TidalArtistRole {
    categoryId: number;
    category: string;
}

export interface TidalArtistProfile {
    id: number;
    name: string;
    artistTypes: string[];
    url: string;
    picture: string | null;
    selectedAlbumCoverFallback: string | null;
    popularity: number;
    artistRoles: TidalArtistRole[];
    mixes: Record<string, string>;
    handle: string | null;
    userId: number | null;
    spotlighted: boolean;
}

export interface TidalMediaMetadata {
    tags: string[];
}

export interface TidalTrackAlbumRef {
    id: number;
    title: string;
    cover: string;
    vibrantColor: string;
    videoCover: string | null;
}

export interface TidalTrack {
    id: number;
    title: string;
    duration: number;
    replayGain: number;
    peak: number;
    allowStreaming: boolean;
    streamReady: boolean;
    payToStream: boolean;
    adSupportedStreamReady: boolean;
    djReady: boolean;
    stemReady: boolean;
    streamStartDate: string;
    premiumStreamingOnly: boolean;
    trackNumber: number;
    volumeNumber: number;
    version: string | null;
    popularity: number;
    copyright: string;
    bpm: number | null;
    key: string | null;
    keyScale: string | null;
    url: string;
    isrc: string;
    editable: boolean;
    explicit: boolean;
    audioQuality: string;
    audioModes: string[];
    mediaMetadata: TidalMediaMetadata;
    upload: boolean;
    accessType: string;
    spotlighted: boolean;
    artist: TidalArtistRef;
    artists: TidalArtistRef[];
    album: TidalTrackAlbumRef;
    mixes: Record<string, string>;
}

export interface TidalPlaylistTrack extends TidalTrack {
    description: string | null;
    dateAdded: string;
    index: number;
    itemUuid: string;
}

export interface TidalAlbum {
    id: number;
    title: string;
    duration: number;
    streamReady: boolean;
    payToStream: boolean;
    adSupportedStreamReady: boolean;
    djReady: boolean;
    stemReady: boolean;
    streamStartDate: string;
    allowStreaming: boolean;
    premiumStreamingOnly: boolean;
    numberOfTracks: number;
    numberOfVideos: number;
    numberOfVolumes: number;
    releaseDate: string;
    copyright: string;
    type: string;
    version: string | null;
    url: string;
    cover: string;
    vibrantColor: string;
    videoCover: string | null;
    explicit: boolean;
    upc: string;
    popularity: number;
    audioQuality: string;
    audioModes: string[];
    mediaMetadata: TidalMediaMetadata;
    upload: boolean;
    artist?: TidalArtistRef;
    artists: TidalArtistRef[];
}

export interface TidalVideoItem {
    id: number;
    title: string;
    duration: number;
    version: string | null;
    url: string;
    artists: TidalArtistRef[];
    album: TidalTrackAlbumRef | null;
    explicit: boolean;
    volumeNumber: number;
    trackNumber: number;
    popularity: number;
    doublePopularity?: number;
    allowStreaming: boolean;
    streamReady: boolean;
    streamStartDate: string;
    adSupportedStreamReady: boolean;
    djReady: boolean;
    stemReady: boolean;
    imageId: string;
    imagePath?: string | null;
    vibrantColor: string;
    releaseDate: string;
    type: string;
    adsUrl: string | null;
    adsPrePaywallOnly: boolean;
    quality?: string;
}

export interface TidalVideoPageModule {
    id: string;
    type: string;
    width: number;
    scroll: string;
    title: string;
    description: string;
    showMore: string | null;
    pagedList: {
        dataApiPath: string;
        limit: number;
        offset: number;
        totalNumberOfItems: number;
        items: TidalVideoItem[];
    };
    supportsPaging: boolean;
    showTableHeaders: boolean;
    listFormat: string;
    layout: string | null;
    quickPlay: boolean;
    preTitle: string | null;
}

export interface TidalSimilarAlbum {
    id: number;
    title: string;
    barcodeId: string;
    numberOfVolumes: number;
    numberOfItems: number;
    duration: string;
    explicit: boolean;
    releaseDate: string;
    copyright: { text: string };
    popularity: number;
    accessType: string;
    availability: string[];
    mediaTags: string[];
    externalLinks: Array<{ href: string; meta: { type: string } }>;
    type: string;
    albumType: string;
    createdAt?: string;
    cover: string;
    artists: Array<{ id: number; name: string }>;
    url: string;
}

export interface RootResponse extends VersionedResponse {
    Repo: string;
}

export interface InfoResponse extends VersionedResponse {
    data: TidalTrack;
}

export interface TrackResponse extends VersionedResponse {
    data: PlaybackInfo;
}

export interface RecommendationsResponse extends VersionedResponse {
    data: unknown;
}

export interface SimilarArtist {
    id: number;
    name: string;
    picture: string | null;
    url: string;
    relationType: string;
    popularity: number;
    externalLinks: Array<{ href: string; meta: { type: string } }>;
    spotlighted: boolean;
    contributionsEnabled: boolean;
}

export interface SimilarArtistsResponse extends VersionedResponse {
    artists: SimilarArtist[];
}

export interface SimilarAlbumsResponse extends VersionedResponse {
    albums: TidalSimilarAlbum[];
}

export interface ArtistCover {
    id: number;
    name: string;
    '750': string;
}

export interface ArtistByIdResponse extends VersionedResponse {
    artist: TidalArtistProfile;
    cover: ArtistCover | null;
}

export interface ArtistDiscographyResponse extends VersionedResponse {
    albums: { items: TidalAlbum[] };
    tracks?: TidalTrack[];
}

export type ArtistResponse = ArtistByIdResponse | ArtistDiscographyResponse;

export interface ArtistBiography {
    source: string;
    lastUpdated: string;
    text: string;
    summary: string;
}

export interface ArtistBioResponse extends VersionedResponse {
    data: ArtistBiography;
}

export interface CoverEntry {
    id: number;
    name: string;
    '1280': string;
    '640': string;
    '80': string;
}

export interface CoverResponse extends VersionedResponse {
    covers: CoverEntry[];
}

export interface TidalSearchBucket<T> {
    limit: number;
    offset: number;
    totalNumberOfItems: number;
    items: T[];
}

export interface SearchResponse extends VersionedResponse {
    data: {
        artists?: TidalSearchBucket<TidalArtistProfile>;
        albums?: TidalSearchBucket<TidalAlbum>;
        tracks?: TidalSearchBucket<TidalTrack>;
        videos?: TidalSearchBucket<TidalVideoItem>;
        playlists?: TidalSearchBucket<TidalPlaylist>;
        genres?: TidalSearchBucket<TidalGenre>;
        topHit?: { value: TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem; type: string };
        topHits?: Array<TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem>;
    };
}

export interface TidalAlbumWithTracks extends TidalAlbum {
    items: Array<{ item: TidalTrack; type: string }>;
}

export interface AlbumResponse extends VersionedResponse {
    data: TidalAlbumWithTracks;
}

export interface TidalPromotedArtist {
    id: number;
    name: string;
    handle: string | null;
    type: string;
    picture: string | null;
}

export interface TidalPlaylist {
    uuid: string;
    title: string;
    numberOfTracks: number;
    numberOfVideos: number;
    creator: { id: number };
    description: string;
    duration: number;
    lastUpdated: string;
    created: string;
    type: string;
    publicPlaylist: boolean;
    url: string;
    image: string;
    popularity: number;
    squareImage?: string;
    customImageUrl: string | null;
    promotedArtists: TidalPromotedArtist[];
    lastItemAddedAt: string;
}

export interface PlaylistItem {
    item: TidalPlaylistTrack;
    type: string;
    cut: string | null;
}

export interface PlaylistResponse extends VersionedResponse {
    playlist: TidalPlaylist;
    items: PlaylistItem[];
}

export interface Mix {
    id: string;
    title: string;
    subTitle?: string;
}

export interface MixResponse extends VersionedResponse {
    mix: unknown;
    items: TidalTrack[];
}

export interface Lyrics {
    trackId: number;
    lyricsProvider: string;
    providerCommontrackId: string;
    providerLyricsId: string;
    lyrics: string;
    subtitles: string;
    isRightToLeft: boolean;
}

export interface LyricsResponse extends VersionedResponse {
    lyrics: unknown;
}

export interface VideoPlaybackInfo {
    videoId: number;
    streamType: string;
    assetPresentation: string;
    videoQuality: string;
    manifestMimeType: string;
    manifestHash: string;
    manifest: string;
}

export interface VideoResponse extends VersionedResponse {
    video: VideoPlaybackInfo;
}

export interface TopVideosResponse extends VersionedResponse {
    videos: TidalVideoItem[];
    total: number;
}

export interface TidalAudioNormData {
    replayGain: number;
    peakAmplitude: number;
}

export interface DrmData {
    drmSystem: string;
    licenseUrl: string;
    certificateUrl: string;
    initData: string | null;
}

export interface TrackManifestAttributes {
    trackPresentation: string;
    previewReason?: string;
    uri: string;
    hash: string;
    formats: string[];
    albumAudioNormalizationData: TidalAudioNormData;
    trackAudioNormalizationData: TidalAudioNormData;
    drmData?: DrmData;
}

export interface TrackManifestResource {
    id: string;
    type: string;
    attributes: TrackManifestAttributes;
}

export interface TrackManifestApiResponse {
    data: TrackManifestResource;
    links: { self: string };
}

export interface TrackManifestResponse extends VersionedResponse {
    data: TrackManifestApiResponse;
}

export interface TidalGenre {
    id: string;
    name: string;
}

type JsonApiRef = { id: string; type: string };

interface JsonApiIncludeAttributes {
    name?: string;
    popularity?: number;
    externalLinks?: Array<{ href: string; meta: { type: string } }>;
    spotlighted?: boolean;
    contributionsEnabled?: boolean;
    selectedAlbumCoverFallback?: string | null;
    files?: Array<{ href: string }>;
    title?: string;
    barcodeId?: string;
    numberOfVolumes?: number;
    numberOfItems?: number;
    duration?: string;
    explicit?: boolean;
    releaseDate?: string;
    copyright?: { text: string };
    accessType?: string;
    availability?: string[];
    mediaTags?: string[];
    albumType?: string;
    createdAt?: string;
    type?: string;
    text?: string;
    source?: string;
}

interface JsonApiInclude {
    id: string;
    type: string;
    attributes: JsonApiIncludeAttributes;
    relationships?: Record<string, { data?: JsonApiRef[] }>;
}

interface JsonApiListResponse {
    data?: JsonApiRef[];
    included?: JsonApiInclude[];
}

interface TidalListResponse<T> {
    items?: T[];
    totalNumberOfItems?: number;
}

interface TidalPageModule {
    type: string;
    mix?: Mix;
    item?: TidalVideoItem;
    pagedList?: { items: Array<{ item?: TidalTrack | TidalVideoItem }> };
}

interface TidalPageRow {
    modules?: TidalPageModule[];
}

interface TidalPagesApiResponse {
    rows?: TidalPageRow[];
}

function isTidalTrack(v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }): v is TidalTrack {
    return 'trackNumber' in v;
}

function isTidalVideoItem(
    v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }
): v is TidalVideoItem {
    return 'imageId' in v;
}

class HiFiClient {
    static readonly API_VERSION = '2.7';
    static readonly DEFAULT_PUBLIC_TOKEN = '49YxDN9a2aFV6RTG';
    static readonly TIDAL_BASE_URL = 'https://tidal.com/v1';

    static #instance: HiFiClient | null = null;
    static get instance() {
        if (!HiFiClient.#instance) {
            throw new Error('HiFiClient is not initialized. Call HiFiClient.initialize(options) first.');
        }
        return HiFiClient.#instance;
    }

    readonly #publicToken: string;
    readonly #countryCode: string;
    readonly #locale: string;
    readonly #deviceType: string;
    readonly #baseUrl: string;

    #useStorage(storage: Pick<Storage, 'setItem' | 'removeItem'>) {
        this.#publicToken && storage.setItem('tidal_web_token', this.#publicToken);
    }

    static #jsonResponse<T>(data: T): TidalResponse<T> {
        return new TidalResponse<T>(JSON.stringify(data), {
            headers: {
                'Content-Type': 'application/json',
            },
        });
    }

    static #buildUrl(base: string, params?: Params | URLSearchParams) {
        if (!params) return base;
        if (params instanceof URLSearchParams) {
            const u = new URL(base);
            u.search = params.toString();
            return u.toString();
        }

        const u = new URL(base);
        Object.entries(params)
            .filter(([, v]) => v !== undefined && v !== null && v !== '')
            .forEach(([k, v]) => u.searchParams.set(k, String(v)));
        return u.toString();
    }

    async #fetchJson<T = unknown>(
        url: string,
        params?: Params | URLSearchParams,
        signal: AbortSignal = new AbortController().signal
    ): Promise<T> {
        const final = HiFiClient.#buildUrl(url, params);

        const headers: Record<string, string> = {
            'x-tidal-token': this.#publicToken,
            'Accept': 'application/json',
        };

        const res = await fetch(final, { headers, signal });

        if (!res.ok) {
            throw new ResponseError(res.status, res.statusText);
        }

        return res.json() as Promise<T>;
    }

    constructor(options: HiFiClient.ConstructorOptions = {}) {
        const {
            publicToken = HiFiClient.DEFAULT_PUBLIC_TOKEN,
            locale = 'en_US',
            countryCode = 'US',
            deviceType = 'BROWSER',
            baseUrl = '',
            storage,
        } = options;

        this.#publicToken = publicToken;
        this.#locale = locale;
        this.#countryCode = countryCode;
        this.#deviceType = deviceType;
        this.#baseUrl = baseUrl || '';

        if (storage) {
            for (const store of !Array.isArray(storage) ? [storage] : storage) {
                this.#useStorage(store);
            }
        }
    }

    static async initialize(options: HiFiClient.ConstructorOptions & { signal?: AbortSignal } = {}) {
        if (HiFiClient.#instance) {
            throw new Error('HiFiClient is already initialized');
        }

        HiFiClient.#instance = new HiFiClient(options);
        return HiFiClient.#instance;
    }

    static #extractUuidFromTidalUrl(href?: string | null) {
        if (!href) return null;
        const parts = href.split('/');
        return parts.length >= 9 ? parts.slice(4, 9).join('-') : null;
    }

    async getInfo(id: number, signal?: AbortSignal): Promise<TidalResponse<InfoResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`;
        const data = await this.#fetchJson<TidalTrack>(url, { countryCode: this.#countryCode }, signal);
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
    }

    async getTrack(
        id: number,
        quality = 'HI_RES_LOSSLESS',
        _immersiveAudio: boolean = false,
        signal?: AbortSignal
    ): Promise<TidalResponse<TrackResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/playbackinfo`;
        const params = {
            audioquality: quality,
            playbackmode: 'STREAM',
            assetpresentation: 'FULL',
            countryCode: this.#countryCode,
        };
        const data = await this.#fetchJson<PlaybackInfo>(url, params, signal);
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
    }

    async getRecommendations(id: number, signal?: AbortSignal): Promise<TidalResponse<RecommendationsResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/recommendations`;
        const data = await this.#fetchJson<{ items: TidalTrack[]; totalNumberOfItems: number }>(
            url,
            { limit: '20', countryCode: this.#countryCode },
            signal
        );
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
    }

    async getSimilarArtists(
        id: number,
        _cursor?: string | number | null,
        signal?: AbortSignal
    ): Promise<TidalResponse<SimilarArtistsResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/similar`;
        const data = await this.#fetchJson<{ items: SimilarArtist[] }>(
            url,
            { countryCode: this.#countryCode },
            signal
        );
        return HiFiClient.#jsonResponse({
            version: HiFiClient.API_VERSION,
            artists: data.items || [],
        });
    }

    async getSimilarAlbums(
        id: number,
        _cursor?: string | number | null,
        signal?: AbortSignal
    ): Promise<TidalResponse<SimilarAlbumsResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/similar`;
        const data = await this.#fetchJson<{ items: TidalSimilarAlbum[] }>(
            url,
            { countryCode: this.#countryCode },
            signal
        );
        return HiFiClient.#jsonResponse({
            version: HiFiClient.API_VERSION,
            albums: data.items || [],
        });
    }

    async getArtist(
        id?: number | null,
        f?: number | null,
        skip_tracks = false,
        signal?: AbortSignal,
        options?: { offset?: number; limit?: number }
    ): Promise<TidalResponse<ArtistResponse>> {
        if (!id && !f) throw new ResponseError(400, 'Provide id or f query param');

        if (id) {
            const artist_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}`;
            const artist_data = await this.#fetchJson<TidalArtistProfile>(
                artist_url,
                { countryCode: this.#countryCode },
                signal
            );

            let cover: ArtistCover | null = null;
            if (artist_data.picture) {
                const slug = artist_data.picture.replace(/-/g, '/');
                cover = {
                    id: artist_data.id,
                    name: artist_data.name,
                    '750': `https://resources.tidal.com/images/${slug}/750x750.jpg`,
                };
            }

            const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/albums`;
            const albums_data = await this.#fetchJson<TidalListResponse<TidalAlbum>>(
                albums_url,
                { countryCode: this.#countryCode, limit: 50 },
                signal
            );

            let top_tracks: TidalTrack[] = [];
            if (skip_tracks) {
                const toptracks_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/toptracks`;
                const params: Params = { countryCode: this.#countryCode, limit: options?.limit || 15 };
                if (options?.offset !== undefined) params.offset = options.offset;
                const toptracks_data = await this.#fetchJson<TidalListResponse<TidalTrack>>(
                    toptracks_url,
                    params,
                    signal
                );
                top_tracks = toptracks_data.items || [];
            }

            return HiFiClient.#jsonResponse({
                version: HiFiClient.API_VERSION,
                artist: artist_data,
                cover,
                albums: { items: albums_data.items || [] },
                tracks: top_tracks,
            });
        }

        const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/albums`;
        const common_params: Params = { countryCode: this.#countryCode, limit: 50 };

        const tasks: Array<Promise<TidalListResponse<TidalAlbum> | TidalListResponse<TidalTrack>>> = [
            this.#fetchJson<TidalListResponse<TidalAlbum>>(albums_url, common_params, signal),
            this.#fetchJson<TidalListResponse<TidalAlbum>>(
                albums_url,
                { ...common_params, filter: 'EPSANDSINGLES' },
                signal
            ),
        ];

        if (skip_tracks) {
            const offset = options?.offset;
            const limit = options?.limit;
            const toptracks_params: Params = { countryCode: this.#countryCode, limit: limit || 15 };
            if (offset !== undefined) {
                toptracks_params.offset = offset;
            }
            tasks.push(
                this.#fetchJson<TidalListResponse<TidalTrack>>(
                    `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/toptracks`,
                    toptracks_params,
                    signal
                )
            );
        }

        const results = await Promise.all(tasks.map((p) => p.catch((e: Error) => e)));

        const unique_releases: TidalAlbum[] = [];
        const seen_ids = new Set<number>();

        for (const res of results.slice(0, 2)) {
            if (res && !(res instanceof Error)) {
                const data = res as TidalListResponse<TidalAlbum>;
                const items = data?.items ?? [];
                for (const item of items) {
                    if (item && item.id && !seen_ids.has(item.id)) {
                        unique_releases.push(item);
                        seen_ids.add(item.id);
                    }
                }
            }
        }

        const page_data = { items: unique_releases };

        if (skip_tracks) {
            let top_tracks: TidalTrack[] = [];
            if (results.length > 2) {
                const res = results[2];
                if (res && !(res instanceof Error)) {
                    const data = res as TidalListResponse<TidalTrack>;
                    top_tracks = data?.items ?? [];
                }
            }
            return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: top_tracks });
        }

        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] });
    }

    async getArtistBiography(artistId: number, signal?: AbortSignal): Promise<TidalResponse<ArtistBioResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${artistId}/bio`;
        const data = await this.#fetchJson<ArtistBiography>(
            url,
            { countryCode: this.#countryCode },
            signal
        );
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
    }

    #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null): CoverEntry {
        const slug = cover_slug.replace(/-/g, '/');
        return {
            id: track_id ?? 0,
            name: name ?? '',
            '1280': `https://resources.tidal.com/images/${slug}/1280x1280.jpg`,
            '640': `https://resources.tidal.com/images/${slug}/640x640.jpg`,
            '80': `https://resources.tidal.com/images/${slug}/80x80.jpg`,
        };
    }

    async getCover(id?: number | null, q?: string | null, signal?: AbortSignal): Promise<TidalResponse<CoverResponse>> {
        if (!id && !q) throw new ResponseError(400, 'Provide id or q query param');

        if (id) {
            const track_data = await this.#fetchJson<TidalTrack>(
                `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`,
                { countryCode: this.#countryCode },
                signal
            );
            const album = track_data.album ?? ({} as TidalTrackAlbumRef);
            const cover_slug = album.cover;
            if (!cover_slug) throw new ResponseError(404, 'Cover not found');
            const entry = this.#buildCoverEntry(cover_slug, album.title || track_data.title, album.id || id);
            return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers: [entry] });
        }

        const search_data = await this.#fetchJson<{ items: TidalTrack[] }>(
            `${HiFiClient.TIDAL_BASE_URL}/search/tracks`,
            { countryCode: this.#countryCode, query: q, limit: 10 },
            signal
        );
        const items = Array.isArray(search_data?.items) ? search_data.items.slice(0, 10) : [];
        if (!items.length) throw new ResponseError(404, 'Cover not found');
        const covers: CoverEntry[] = [];
        for (const track of items) {
            const album = track.album ?? ({} as TidalTrackAlbumRef);
            const cover_slug = album.cover;
            if (!cover_slug) continue;
            covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id));
        }
        if (!covers.length) throw new ResponseError(404, 'Cover not found');
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers });
    }

    async search(
        options: {
            q?: string;
            s?: string;
            a?: string;
            al?: string;
            v?: string;
            p?: string;
            i?: string;
            offset?: number;
            limit?: number;
        },
        signal?: AbortSignal
    ): Promise<TidalResponse<SearchResponse>> {
        const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;

        if (i) {
            try {
                const res = await this.#fetchJson<SearchResponse['data']>(
                    `${HiFiClient.TIDAL_BASE_URL}/tracks`,
                    {
                        'filter[isrc]': i,
                        limit,
                        offset,
                        countryCode: this.#countryCode,
                    },
                    signal
                );
                return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
            } catch (err: unknown) {
                if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err;
            }
        }

        const searchMap: Array<[string | undefined, string]> = [
            [q, `${HiFiClient.TIDAL_BASE_URL}/search`],
            [s, `${HiFiClient.TIDAL_BASE_URL}/search/tracks`],
            [a, `${HiFiClient.TIDAL_BASE_URL}/search/artists`],
            [al, `${HiFiClient.TIDAL_BASE_URL}/search/albums`],
            [v, `${HiFiClient.TIDAL_BASE_URL}/search/videos`],
            [p, `${HiFiClient.TIDAL_BASE_URL}/search/playlists`],
        ];

        for (const [val, url] of searchMap) {
            if (val) {
                const params: Params = {
                    query: val,
                    limit,
                    offset,
                    countryCode: this.#countryCode,
                };
                const data = await this.#fetchJson<any>(url, params, signal);
                const normalized: SearchResponse['data'] = {};

                if (data.items) {
                    const bucketKey = url.includes('/tracks') ? 'tracks'
                        : url.includes('/artists') ? 'artists'
                        : url.includes('/albums') ? 'albums'
                        : url.includes('/videos') ? 'videos'
                        : url.includes('/playlists') ? 'playlists'
                        : null;

                    if (bucketKey) {
                        normalized[bucketKey] = {
                            items: data.items,
                            totalNumberOfItems: data.totalNumberOfItems || data.items.length,
                            limit: data.limit || limit,
                            offset: data.offset || offset,
                        };
                    } else {
                        normalized.topHit = { value: data.items[0], type: data.items[0]?.type || '' };
                    }
                }

                return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: normalized });
            }
        }

        throw new Error('Provide one of q, s, a, al, v, p, or i');
    }

    async getAlbum(id: number, limit = 100, offset = 0, signal?: AbortSignal): Promise<TidalResponse<AlbumResponse>> {
        const albumUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}`;
        const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/items`;

        const albumRaw = await this.#fetchJson<TidalAlbum>(albumUrl, { countryCode: this.#countryCode }, signal);

        const allItems: Array<{ item: TidalTrack; type: string }> = [];
        let remaining = limit;
        let currentOffset = offset;
        const maxChunk = 100;

        while (remaining > 0) {
            const chunk = Math.min(remaining, maxChunk);
            const page = await this.#fetchJson<{ items?: Array<{ item: TidalTrack; type: string }> }>(
                itemsUrl,
                { countryCode: this.#countryCode, limit: chunk, offset: currentOffset },
                signal
            );
            const pageItems = page?.items ?? [];
            if (Array.isArray(pageItems)) allItems.push(...pageItems);
            currentOffset += chunk;
            remaining -= chunk;
        }

        const albumData: TidalAlbumWithTracks = { ...albumRaw, items: allItems };
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData });
    }

    async getMix(id: string, signal?: AbortSignal): Promise<TidalResponse<MixResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mix`;
        const data = await this.#fetchJson<TidalPagesApiResponse>(
            url,
            { mixId: id, countryCode: this.#countryCode, deviceType: this.#deviceType },
            signal
        );
        let header: unknown = {};
        let items: TidalTrack[] = [];
        const rows = data.rows ?? [];

        for (const row of rows) {
            for (const module of row.modules ?? []) {
                if (module.type === 'MIX_HEADER') header = module.mix ?? {};
                if (module.type === 'TRACK_LIST') items = ((module.pagedList || {}).items as TidalTrack[]) ?? [];
            }
        }
        return HiFiClient.#jsonResponse({
            version: HiFiClient.API_VERSION,
            mix: header,
            items,
        });
    }

    async getPlaylist(
        id: string,
        limit = 100,
        offset = 0,
        signal?: AbortSignal
    ): Promise<TidalResponse<PlaylistResponse>> {
        const playlistUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}`;
        const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}/items`;
        const [playlistData, itemsData] = await Promise.all([
            this.#fetchJson<TidalPlaylist>(playlistUrl, { countryCode: this.#countryCode }, signal),
            this.#fetchJson<{ items: PlaylistItem[] }>(
                itemsUrl,
                { countryCode: this.#countryCode, limit, offset },
                signal
            ),
        ]);
        const items = itemsData?.items ?? [];
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, playlist: playlistData, items });
    }

    async getLyrics(id: number, signal?: AbortSignal): Promise<TidalResponse<LyricsResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/lyrics`;
        const data = await this.#fetchJson<Lyrics>(
            url,
            { countryCode: this.#countryCode, locale: this.#locale, deviceType: this.#deviceType },
            signal
        );
        if (!data) {
            const err = Object.assign(new Error('Lyrics not found'), { status: 404 });
            throw err;
        }
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, lyrics: data });
    }

    async getVideo(
        id: number,
        quality = 'HIGH',
        mode = 'STREAM',
        presentation = 'FULL',
        signal?: AbortSignal
    ): Promise<TidalResponse<VideoResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/videos/${id}/playbackinfo`;
        const data = await this.#fetchJson<VideoPlaybackInfo>(
            url,
            { videoquality: quality, playbackmode: mode, assetpresentation: presentation },
            signal
        );
        return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data });
    }

    async getTopVideos(
        { countryCode = 'US', locale = 'en_US', deviceType = 'BROWSER', limit = 25, offset = 0 } = {},
        signal?: AbortSignal
    ): Promise<TidalResponse<TopVideosResponse>> {
        const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mymusic_recommended_videos`;
        const data = await this.#fetchJson<TidalPagesApiResponse>(url, { countryCode, locale, deviceType }, signal);
        const rows = data.rows ?? [];
        const videos: TidalVideoItem[] = [];
        for (const row of rows) {
            for (const module of row.modules ?? []) {
                const mt = module.type;
                if (['VIDEO_PLAYLIST', 'VIDEO_ROW', 'PAGED_LIST'].includes(mt)) {
                    const items = module.pagedList?.items ?? [];
                    for (const item of items) {
                        const v = item.item ?? item;
                        videos.push(v as TidalVideoItem);
                    }
                } else if (mt === 'VIDEO' || (mt && mt.toLowerCase().includes('video'))) {
                    videos.push(module.item as TidalVideoItem);
                }
            }
        }
        return HiFiClient.#jsonResponse({
            version: HiFiClient.API_VERSION,
            videos: videos.slice(offset, offset + limit),
            total: videos.length,
        });
    }

    async query(pathOrUrl: string, signal?: AbortSignal): Promise<TidalResponse> {
        try {
            const u = new URL(pathOrUrl, 'http://localhost');
            const pathname = u.pathname.replace(/\/+$/, '') || '/';
            const qp: Record<string, string> = {};
            u.searchParams.forEach((v, k) => (qp[k] = v));

            switch (pathname) {
                case '/':
                    return new TidalResponse(
                        HiFiClient.#jsonResponse({
                            version: HiFiClient.API_VERSION,
                            Repo: 'https://github.com/binimum/hifi-api',
                        })
                    );
                case '/info':
                    return new TidalResponse(await this.getInfo(Number(qp.id)));
                case '/track':
                    return new TidalResponse(await this.getTrack(Number(qp.id), qp.quality || undefined));
                case '/recommendations':
                    return new TidalResponse(await this.getRecommendations(Number(qp.id)));
                case '/artist/similar':
                    return new TidalResponse(
                        await this.getSimilarArtists(Number(qp.id), qp.cursor ?? undefined, signal)
                    );
                case '/album/similar':
                    return new TidalResponse(
                        await this.getSimilarAlbums(Number(qp.id), qp.cursor ?? undefined, signal)
                    );
                case '/artist/bio':
                    return new TidalResponse(await this.getArtistBiography(Number(qp.id), signal));
                case '/artist':
                    return new TidalResponse(
                        await this.getArtist(
                            qp.id ? Number(qp.id) : undefined,
                            qp.f ? Number(qp.f) : undefined,
                            qp.skip_tracks === 'true' || qp.skip_tracks === '1' || qp.skip_tracks === 'True',
                            signal,
                            {
                                offset: qp.offset !== undefined ? Number(qp.offset) : undefined,
                                limit: qp.limit !== undefined ? Number(qp.limit) : undefined,
                            }
                        )
                    );
                case '/cover':
                    return new TidalResponse(
                        await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal)
                    );
                case '/search':
                    return new TidalResponse(
                        await this.search({
                            q: qp.q,
                            s: qp.s,
                            a: qp.a,
                            al: qp.al,
                            v: qp.v,
                            p: qp.p,
                            i: qp.i,
                            offset: qp.offset ? Number(qp.offset) : undefined,
                            limit: qp.limit ? Number(qp.limit) : undefined,
                        })
                    );
                case '/album':
                    return new TidalResponse(
                        await this.getAlbum(
                            Number(qp.id),
                            qp.limit ? Number(qp.limit) : undefined,
                            qp.offset ? Number(qp.offset) : undefined
                        )
                    );
                case '/playlist':
                    return new TidalResponse(
                        await this.getPlaylist(
                            qp.id || '',
                            qp.limit ? Number(qp.limit) : undefined,
                            qp.offset ? Number(qp.offset) : undefined
                        )
                    );
                case '/mix':
                    return new TidalResponse(await this.getMix(qp.id || ''));
                case '/lyrics':
                    return new TidalResponse(await this.getLyrics(Number(qp.id)));
                case '/video':
                    return new TidalResponse(
                        await this.getVideo(
                            Number(qp.id),
                            qp.quality || undefined,
                            qp.mode || undefined,
                            qp.presentation || undefined
                        )
                    );
                case '/topvideos':
                    return new TidalResponse(
                        await this.getTopVideos({
                            countryCode: qp.countryCode || undefined,
                            locale: qp.locale || undefined,
                            deviceType: qp.deviceType || undefined,
                            limit: qp.limit ? Number(qp.limit) : undefined,
                            offset: qp.offset ? Number(qp.offset) : undefined,
                        })
                    );
                default:
                    throw new Error(`Unknown route: ${pathname}`);
            }
        } catch (err) {
            const message = (err as { message?: string }).message ?? String(err);
            console.error(message, err);
            throw err;
        }
    }
}

namespace HiFiClient {
    export interface ConstructorOptions {
        publicToken?: string;
        locale?: string;
        countryCode?: string;
        deviceType?: string;
        baseUrl?: string;
        storage?: Pick<Storage, 'setItem' | 'removeItem'>[] | Pick<Storage, 'setItem' | 'removeItem'>;
    }
}

export { HiFiClient };
