import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '$test-utils/query';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { apiClientMock } from '$test-utils/mocks/client';
import { emptyLikesMock } from '$test-utils/mocks/likes';
import type { AlbumRef } from '$lib/api/types';

const pageState = vi.hoisted(() => ({
  pageUrl: new URL('http://localhost/library/genres')
}));

vi.mock('$app/state', () => pageUrlModule(pageState));

vi.mock('$lib/api/browse', () => ({
  BROWSE_PAGE_SIZE: 2,
  createGenresQuery: vi.fn(),
  listAlbumsByGenre: vi.fn()
}));

vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/player/store.svelte', () => ({
  playQueue: vi.fn(),
  playRadio: vi.fn(),
  enqueueTrack: vi.fn(),
  enqueueTracks: vi.fn(),
  player: { current: undefined }
}));

import GenresPage from './+page.svelte';
import { createGenresQuery, listAlbumsByGenre } from '$lib/api/browse';

const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;

function album(id: string, title: string): AlbumRef {
  return {
    id,
    title,
    sort_title: title,
    artist_id: 'ar1',
    artist_name: 'Someone',
    year: 1999,
    track_count: 1,
    duration_sec: 100,
    cover_url: '',
    cover_art_source: null
  };
}

afterEach(() => {
  pageState.pageUrl = new URL('http://localhost/library/genres');
  vi.clearAllMocks();
});

describe('/library/genres index', () => {
  test('lists genres with track counts in server order', () => {
    asMock(createGenresQuery).mockReturnValue(
      mockQuery({
        data: [
          { genre: 'Rock', track_count: 120 },
          { genre: 'Jazz', track_count: 8 }
        ]
      })
    );
    render(GenresPage);

    expect(screen.getByText('Rock')).toBeInTheDocument();
    expect(screen.getByText('120')).toBeInTheDocument();
    expect(screen.getByText('Jazz')).toBeInTheDocument();
    expect(screen.getByText('8')).toBeInTheDocument();
  });

  // The reason genre is a query parameter and not a route segment. If this
  // regresses to a path, "Rock/Pop" silently becomes two segments.
  test('encodes a slash-bearing genre into the query string', () => {
    asMock(createGenresQuery).mockReturnValue(
      mockQuery({ data: [{ genre: 'Rock/Pop', track_count: 3 }] })
    );
    render(GenresPage);

    const link = screen.getByRole('link', { name: /Rock\/Pop/ });
    expect(link.getAttribute('href')).toBe('/library/genres?g=Rock%2FPop');
  });

  test('case variants appear separately — genres are exposed as-is', () => {
    asMock(createGenresQuery).mockReturnValue(
      mockQuery({
        data: [
          { genre: 'Rock', track_count: 10 },
          { genre: 'rock', track_count: 2 }
        ]
      })
    );
    render(GenresPage);

    expect(screen.getByRole('link', { name: /^Rock 10$/ })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument();
  });

  // #2468: the operator's real library is 391 genres at ~3.7 per track, so the
  // count-first default buries anything you can already name. A–Z is the ask.
  test('A–Z sort reorders the list without mutating the source array', async () => {
    const data = [
      { genre: 'Rock', track_count: 8974 },
      { genre: 'Ambient', track_count: 646 },
      { genre: 'jazz', track_count: 1406 }
    ];
    asMock(createGenresQuery).mockReturnValue(mockQuery({ data }));
    render(GenresPage);

    const order = () =>
      screen.getAllByRole('link').map((a) => a.textContent?.trim().split(/\s+/)[0]);

    // Count mode PRESERVES the server's order rather than re-sorting client
    // side — the server already returns count DESC, name ASC, and duplicating
    // that here would be two orderings to keep in step. The fixture is
    // deliberately not in count order so this asserts pass-through, not luck.
    expect(order()).toEqual(['Rock', 'Ambient', 'jazz']);

    await fireEvent.change(screen.getByLabelText('Sort genres'), { target: { value: 'name' } });

    // Case-insensitive, so 'jazz' sorts between Ambient and Rock rather than
    // after both.
    expect(order()).toEqual(['Ambient', 'jazz', 'Rock']);

    // The query cache's own array must not have been reordered in place —
    // Array.sort mutates, and with no filter applied the derived list IS that
    // array.
    expect(data.map((d) => d.genre)).toEqual(['Rock', 'Ambient', 'jazz']);
  });

  test('filtering reports the visible subset against the total', async () => {
    asMock(createGenresQuery).mockReturnValue(
      mockQuery({
        data: [
          { genre: 'Rock', track_count: 10 },
          { genre: 'Ska Punk', track_count: 5 },
          { genre: 'Jazz', track_count: 3 }
        ]
      })
    );
    render(GenresPage);
    expect(screen.getByText(/3 genres, straight from your file tags/)).toBeInTheDocument();

    await fireEvent.input(screen.getByLabelText('Filter genres'), { target: { value: 'punk' } });

    // QuickFilter debounces by 120ms.
    await waitFor(() => expect(screen.getByText('1 of 3 genres')).toBeInTheDocument());
  });

  test('empty library explains where genres come from', () => {
    asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
    render(GenresPage);

    expect(screen.getByText('No genres found')).toBeInTheDocument();
    expect(screen.getByText(/genre tag on your audio files/i)).toBeInTheDocument();
  });

  test('surfaces an index error with a retry', () => {
    const refetch = vi.fn();
    asMock(createGenresQuery).mockReturnValue(
      mockQuery({ isError: true, error: { message: 'boom' }, refetch })
    );
    render(GenresPage);

    fireEvent.click(screen.getByRole('button', { name: /Try again/i }));
    expect(refetch).toHaveBeenCalled();
  });
});

describe('/library/genres drill-down', () => {
  test('fetches and renders albums for the selected genre', async () => {
    pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock%2FPop');
    asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
    asMock(listAlbumsByGenre).mockResolvedValue({
      items: [album('a1', 'First Album')],
      total: 1,
      limit: 2,
      offset: 0
    });
    render(GenresPage);

    // The decoded genre must reach the API, not the percent-encoded form.
    await waitFor(() => expect(listAlbumsByGenre).toHaveBeenCalledWith('Rock/Pop', 2, 0));
    expect(await screen.findByText('First Album')).toBeInTheDocument();
    expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
  });

  test('load more appends the next page and then reports the end', async () => {
    pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
    asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
    asMock(listAlbumsByGenre)
      .mockResolvedValueOnce({
        items: [album('a1', 'One'), album('a2', 'Two')],
        total: 3,
        limit: 2,
        offset: 0
      })
      .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
    render(GenresPage);

    await screen.findByText('One');
    const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ });
    await fireEvent.click(more);

    await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2));
    expect(await screen.findByText('Three')).toBeInTheDocument();
    // Earlier pages are appended, not replaced.
    expect(screen.getByText('One')).toBeInTheDocument();
    expect(await screen.findByText('End of genre')).toBeInTheDocument();
  });

  test('a failed drill-down offers a retry rather than an empty grid', async () => {
    pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
    asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
    asMock(listAlbumsByGenre).mockRejectedValue(new Error('nope'));
    render(GenresPage);

    expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
  });
});
