import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SearchScreen } from './SearchScreen';

function jsonResponse(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}

const movieItem = { artwork: '', duration: null, id: 12, mediaType: 'Movie', subtitle: null, title: 'Blade Runner' };

function emptyGroup() {
  return { totalCount: 0, items: [] };
}

function searchResults() {
  return {
    movies: { totalCount: 1, items: [movieItem] },
    shows: emptyGroup(),
    seasons: emptyGroup(),
    artists: emptyGroup(),
    episodes: emptyGroup(),
    musicVideos: emptyGroup(),
    songs: emptyGroup(),
    otherVideos: emptyGroup(),
    images: emptyGroup(),
    remoteStreams: emptyGroup()
  };
}

const allItems = {
  movieIds: [7, 8],
  showIds: null,
  seasonIds: null,
  episodeIds: null,
  artistIds: null,
  musicVideoIds: null,
  otherVideoIds: null,
  songIds: null,
  imageIds: null,
  remoteStreamIds: null,
  // Totals equal to this single page's ids, so getAllSearchItemIds treats it as complete after one fetch.
  totals: {
    movieCount: 2,
    showCount: 0,
    seasonCount: 0,
    episodeCount: 0,
    artistCount: 0,
    musicVideoCount: 0,
    otherVideoCount: 0,
    songCount: 0,
    imageCount: 0,
    remoteStreamCount: 0
  }
};

const emptyBuckets = {
  artistIds: [],
  episodeIds: [],
  imageIds: [],
  movieIds: [],
  musicVideoIds: [],
  otherVideoIds: [],
  remoteStreamIds: [],
  seasonIds: [],
  showIds: [],
  songIds: []
};

interface Recorded {
  url: string;
  method: string;
  body: unknown;
}

function mockApi() {
  const calls: Recorded[] = [];
  vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
    const url = input.toString();
    const method = (init?.method ?? 'GET').toUpperCase();
    const body = init?.body ? JSON.parse(String(init.body)) : undefined;
    calls.push({ body, method, url });

    if (url.startsWith('/api/v1/search/all-items')) {
      return Promise.resolve(jsonResponse(allItems));
    }
    if (url.startsWith('/api/v1/search')) {
      return Promise.resolve(jsonResponse(searchResults()));
    }
    if (url === '/api/v1/collections' && method === 'GET') {
      return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }]));
    }
    if (url === '/api/v1/smart-collections' && method === 'GET') {
      return Promise.resolve(jsonResponse([]));
    }
    if (url === '/api/v1/smart-collections' && method === 'POST') {
      const name = (body as { name: string }).name;
      return Promise.resolve(jsonResponse({ id: 42, name, query: (body as { query: string }).query }, 201));
    }
    if (url === '/api/v1/playlists/groups') {
      return Promise.resolve(jsonResponse([]));
    }
    return Promise.resolve(new Response(null, { status: 204 }));
  });

  return { calls };
}

function renderWithQuery() {
  window.history.replaceState(null, '', '/app/search?query=trek');
  return render(<SearchScreen />);
}

function deferred<T>() {
  let resolve!: (value: T) => void;
  const promise = new Promise<T>((r) => {
    resolve = r;
  });
  return { promise, resolve };
}

// A fetch mock whose /api/v1/search (results) and /api/v1/search/all-items responses are held pending
// until the test resolves them, so we can observe the in-flight "refreshing" window (issue #221).
function mockControlledSearch() {
  const searchDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
  const allItemsDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
  vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
    const url = input.toString();
    const method = (init?.method ?? 'GET').toUpperCase();
    if (url.startsWith('/api/v1/search/all-items')) {
      const d = deferred<Response>();
      allItemsDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
      return d.promise;
    }
    if (url.startsWith('/api/v1/search')) {
      const d = deferred<Response>();
      searchDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
      return d.promise;
    }
    if (url === '/api/v1/collections' && method === 'GET') {
      return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }]));
    }
    if (url === '/api/v1/smart-collections') {
      return Promise.resolve(jsonResponse([]));
    }
    if (url === '/api/v1/playlists/groups') {
      return Promise.resolve(jsonResponse([]));
    }
    return Promise.resolve(new Response(null, { status: 204 }));
  });
  return { searchDeferreds, allItemsDeferreds };
}

describe('SearchScreen', () => {
  beforeEach(() => {
    window.localStorage.clear();
    vi.restoreAllMocks();
  });

  afterEach(() => {
    cleanup();
    window.history.replaceState(null, '', '/');
  });

  it('drills into a result card via its detail path', async () => {
    mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
    const pushState = vi.spyOn(window.history, 'pushState');
    fireEvent.click(screen.getByText('Blade Runner'));

    expect(pushState).toHaveBeenCalledWith(null, '', '/app/media/movies/12');
  });

  it('selects cards and adds the selection to a collection', async () => {
    const { calls } = mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    // Enter select mode, then click the card to toggle it selected.
    fireEvent.click(screen.getByRole('button', { name: /Select/ }));
    fireEvent.click(screen.getByText('Blade Runner'));

    await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy());

    fireEvent.click(screen.getByRole('button', { name: 'Add to collection' }));

    const dialog = await screen.findByRole('dialog');
    await waitFor(() => expect(within(dialog).getByRole('option', { name: 'Favorites' })).toBeTruthy());
    fireEvent.click(within(dialog).getByRole('button', { name: /Add to collection/ }));

    await waitFor(() => expect(calls.some((call) => call.url === '/api/v1/collections/1/items')).toBe(true));
    const addCall = calls.find((call) => call.url === '/api/v1/collections/1/items');
    expect(addCall?.method).toBe('POST');
    expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [12] });
  });

  it('clears the selection when the query changes', async () => {
    mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
    fireEvent.click(screen.getByRole('button', { name: /Select/ }));
    fireEvent.click(screen.getByText('Blade Runner'));
    await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy());

    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });

    // The debounced query change drops the now-stale selection.
    await waitFor(() => expect(screen.queryByText('1 selected')).toBeNull());
  });

  it('adds all query results (not just the loaded page) to a collection', async () => {
    const { calls } = mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));

    // The whole-query id set is resolved via /api/v1/search/all-items before the dialog opens.
    await waitFor(() => expect(calls.some((call) => call.url.startsWith('/api/v1/search/all-items'))).toBe(true));

    const dialog = await screen.findByRole('dialog');
    await waitFor(() => expect(within(dialog).getByRole('option', { name: 'Favorites' })).toBeTruthy());
    fireEvent.click(within(dialog).getByRole('button', { name: /Add to collection/ }));

    await waitFor(() => expect(calls.some((call) => call.url === '/api/v1/collections/1/items')).toBe(true));
    const addCall = calls.find((call) => call.url === '/api/v1/collections/1/items');
    expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] });
  });

  it('gates mutation controls and shows a refreshing cue while a new query loads (issue #221)', async () => {
    const ctl = mockControlledSearch();
    renderWithQuery();

    ctl.searchDeferreds[0].resolve(searchResults());
    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    // Baseline: mutation surfaces are live for the current result set.
    expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: /Add all to collection/ })).not.toBeDisabled();
    expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);

    // Change the query: the debounced commit fires a refetch, which we hold pending.
    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
    await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));

    // Refreshing window: cue visible, every mutation surface gated, cards still visible.
    await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
    expect(screen.getByText('Blade Runner')).toBeTruthy();
    expect(screen.getByRole('button', { name: /^Select/ })).toBeDisabled();
    expect(screen.getByRole('button', { name: /Add all to collection/ })).toBeDisabled();
    expect(screen.getByRole('button', { name: /Add all to playlist/ })).toBeDisabled();
    expect(screen.getByRole('button', { name: /Save as smart collection/ })).toBeDisabled();
    expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();

    // Replacement resolves: controls re-enable, cue gone.
    ctl.searchDeferreds[1].resolve(searchResults());
    await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
    expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
    expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
  });

  it('discards an Add-all result when the query changed before it resolved (issue #221)', async () => {
    const ctl = mockControlledSearch();
    renderWithQuery();

    ctl.searchDeferreds[0].resolve(searchResults());
    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    // Kick off Add all (all-items request in flight, dialog not yet open).
    fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
    await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(1));

    // Change the query before all-items resolves, then settle the new query's results.
    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
    await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
    ctl.searchDeferreds[1].resolve(searchResults());
    await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());

    // The stale all-items result now resolves — it must NOT open a dialog for the old query.
    ctl.allItemsDeferreds[0].resolve(allItems);
    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
    expect(screen.queryByRole('dialog')).toBeNull();

    // A fresh Add all for the current query still works.
    fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
    await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(2));
    ctl.allItemsDeferreds[1].resolve(allItems);
    expect(await screen.findByRole('dialog')).toBeTruthy();
  });

  it('does not show a stuck refreshing cue after the query is cleared to empty (#222 review)', async () => {
    mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    // `load()` early-returns for a blank query, so `state` never updates once cleared — the
    // `refreshing` derivation must be gated on `hasQuery`, or the cue gets stuck forever over the
    // "Type a query…" empty card.
    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: '' } });

    await waitFor(() =>
      expect(screen.getByText('Type a query to search across every media kind.')).toBeTruthy()
    );
    expect(screen.queryByText('Refreshing…')).toBeNull();
  });

  it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
    const ctl = mockControlledSearch();
    renderWithQuery();

    ctl.searchDeferreds[0].resolve(searchResults());
    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    fireEvent.click(screen.getByRole('button', { name: /^Select/ }));

    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
    await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
    await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());

    const pushState = vi.spyOn(window.history, 'pushState');
    fireEvent.click(screen.getByText('Blade Runner'));

    // Neither selection nor navigation fires — the card is fully inert during select+refresh.
    expect(screen.queryByText('1 selected')).toBeNull();
    expect(pushState).not.toHaveBeenCalled();
  });

  it('select toggle: entering select mode is blocked while refreshing, exiting is allowed (#222 review)', async () => {
    const ctl = mockControlledSearch();
    renderWithQuery();

    ctl.searchDeferreds[0].resolve(searchResults());
    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    // Not in select mode; trigger a refetch — entering select mode must be blocked.
    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
    await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
    await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
    const enterToggle = screen.getByRole('button', { name: /^Select/ });
    expect(enterToggle).toBeDisabled();
    expect(enterToggle.className).toContain('ctv-button-secondary');

    // Settle, enter select mode, then trigger another refetch — exiting must stay enabled.
    ctl.searchDeferreds[1].resolve(searchResults());
    await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
    fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
    expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-primary');

    fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'aliens' } });
    await waitFor(() => expect(ctl.searchDeferreds.length).toBe(3));
    await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());

    const exitToggle = screen.getByRole('button', { name: /^Select/ });
    expect(exitToggle).not.toBeDisabled();
    fireEvent.click(exitToggle);
    expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-secondary');
  });

  it('saves the current query as a new smart collection', async () => {
    const { calls } = mockApi();
    renderWithQuery();

    await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());

    fireEvent.click(screen.getByRole('button', { name: /Save as smart collection/ }));

    const dialog = await screen.findByRole('dialog');
    await waitFor(() => expect(within(dialog).getByRole('option', { name: '(New smart collection)' })).toBeTruthy());
    fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Trek Stuff' } });
    fireEvent.click(within(dialog).getByRole('button', { name: /Save as smart collection/ }));

    await waitFor(() => expect(calls.some((call) => call.url === '/api/v1/smart-collections' && call.method === 'POST')).toBe(true));
    const createCall = calls.find((call) => call.url === '/api/v1/smart-collections' && call.method === 'POST');
    expect(createCall?.body).toEqual({ name: 'Trek Stuff', query: 'trek' });
  });
});
