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

// The channel preview panel mounts HlsPlayer, which imports hls.js. jsdom has no MediaSource, so
// hls.js must never touch the real implementation here — mocked wholesale, same shape as
// channels/ChannelPreviewPanel.test.tsx.
const hlsMock = {
  isSupported: true,
  loadSource: vi.fn(),
  attachMedia: vi.fn(),
  on: vi.fn(),
  destroy: vi.fn()
};

vi.mock('hls.js', () => {
  class MockHls {
    static isSupported() {
      return hlsMock.isSupported;
    }
    static Events = { MANIFEST_PARSED: 'hlsManifestParsed', ERROR: 'hlsError' };
    loadSource = hlsMock.loadSource;
    attachMedia = hlsMock.attachMedia;
    on = hlsMock.on;
    destroy = hlsMock.destroy;
  }
  return { default: MockHls };
});

// The preview panel resolves a tokened src via withIptvToken (#552); mock it to identity so opening the
// panel doesn't fire a real /api/v1/auth/iptv-token fetch. Behavior is covered in media/iptvToken.test.ts.
vi.mock('../media/iptvToken', () => ({
  withIptvToken: (url: string) => Promise.resolve(url),
  resetIptvTokenCache: () => {}
}));

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

// ChannelResponseModel row (runtime/Newtonsoft casing — see spa-conventions §4).
function channelRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    ffmpegProfile: 'HLS Direct',
    group: 'Kids',
    id: 1,
    isEnabled: true,
    language: 'en',
    name: 'Retro Cartoons',
    number: '5.1',
    // The API always sends playoutCount; default to a healthy channel so only tests that opt in
    // render a fault state.
    playoutCount: 1,
    // Server-derived per-channel fault taxonomy (#415). Defaults to a clean bill of health so only
    // tests that opt in render a problem badge / count toward the "Problems" filter.
    health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 },
    // Server-derived preview eligibility (Task 2). Defaults to Unavailable so most existing
    // fixtures keep asserting the disabled preview button; tests that need a different
    // availability override this explicitly.
    preview: { availability: 'Unavailable', manifestUrl: null, unavailableReason: null },
    showInEpg: true,
    sortNumber: 5.1,
    streamingMode: 'HLS Direct',
    ...overrides
  };
}

// ChannelStateResponseModel row.
function stateRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    channelId: 1,
    channelNumber: '5.1',
    nowPlaying: null,
    onAir: false,
    ...overrides
  };
}

function ffmpegSettingsFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    defaultFFmpegProfileId: 1,
    ...overrides
  };
}

interface MockOptions {
  channels?: unknown[];
  channelStates?: unknown[];
  createChannelResponse?: { id: number } | null;
  createChannelFailure?: { detail?: string; status?: number; title?: string } | null;
  ffmpegSettings?: Record<string, unknown>;
  // path -> ProblemDetails body returned with a 422 for a bulk mutation.
  mutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
  watermarks?: unknown[] | null;
  watermarksFail?: boolean;
}

interface MockHandle {
  fetchSpy: ReturnType<typeof vi.spyOn>;
  requests: Array<{ url: string; method: string; body: unknown }>;
}

function mockApi(options: MockOptions = {}): MockHandle {
  const requests: Array<{ url: string; method: string; body: unknown }> = [];
  const channels = options.channels ?? [channelRow()];
  const channelStates = options.channelStates ?? [];
  const ffmpegSettings = options.ffmpegSettings ?? ffmpegSettingsFixture();
  const mutationFailures = options.mutationFailures ?? {};
  const watermarks = options.watermarks ?? [];

  const fetchSpy = 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;
    requests.push({ url, method, body });

    if (url === '/api/v1/channels' && method === 'POST') {
      if (options.createChannelFailure) {
        return Promise.resolve(jsonResponse(options.createChannelFailure, options.createChannelFailure.status ?? 422));
      }
      return Promise.resolve(jsonResponse(options.createChannelResponse ?? { id: 99 }, 201));
    }
    if (url === '/api/v1/channels' && method === 'GET') return Promise.resolve(jsonResponse(channels));
    if (url === '/api/v1/channels/state' && method === 'GET') return Promise.resolve(jsonResponse(channelStates));
    if (url === '/api/v1/settings/ffmpeg' && method === 'GET') return Promise.resolve(jsonResponse(ffmpegSettings));
    if (url === '/api/v1/watermarks' && method === 'GET') {
      if (options.watermarksFail) {
        return Promise.resolve(new Response(null, { status: 500 }));
      }
      return Promise.resolve(jsonResponse(watermarks));
    }

    if (
      url === '/api/v1/channels/bulk/renumber' ||
      url === '/api/v1/channels/bulk/group' ||
      url === '/api/v1/channels/bulk/delete'
    ) {
      if (url in mutationFailures) {
        return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
      }
      return Promise.resolve(new Response(null, { status: 204 }));
    }

    if (/^\/api\/v1\/channels\/\d+$/.test(url) && method === 'DELETE') {
      if (url in mutationFailures) {
        return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
      }
      return Promise.resolve(new Response(null, { status: 204 }));
    }

    return Promise.resolve(jsonResponse(null, 404));
  });

  return { fetchSpy, requests };
}

function fetchCount(handle: MockHandle, path: string): number {
  return handle.requests.filter((r) => r.url === path).length;
}

function requestBodyFor(handle: MockHandle, path: string): Record<string, unknown> {
  const call = handle.requests.find((r) => r.url === path && r.body != null);
  if (!call) {
    throw new Error(`no request captured for ${path}`);
  }
  return call.body as Record<string, unknown>;
}

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

beforeEach(() => {
  window.history.replaceState(null, '', '/app/channels');
  vi.spyOn(window, 'confirm').mockReturnValue(true);
  vi.spyOn(window, 'prompt').mockReturnValue(null);
});

describe('ChannelsScreen — load + render', () => {
  it('renders channels from live channel and state APIs', async () => {
    const handle = mockApi({
      channels: [
        channelRow({ id: 1, group: 'Kids', name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1, streamingMode: 'HLS Direct' }),
        channelRow({
          ffmpegProfile: 'MPEG-TS',
          group: 'News',
          id: 2,
          isEnabled: false,
          language: 'fr',
          name: 'News 24',
          number: '24',
          showInEpg: false,
          sortNumber: 24,
          streamingMode: 'MPEG-TS'
        })
      ],
      channelStates: [
        stateRow({
          channelId: 1,
          channelNumber: '5.1',
          nowPlaying: {
            finishUtc: '2026-07-04T21:30:00Z',
            startUtc: '2026-07-04T21:00:00Z',
            title: 'Saturday Morning Cartoons'
          },
          onAir: true
        }),
        stateRow({ channelId: 2, channelNumber: '24', nowPlaying: null, onAir: false })
      ]
    });

    render(<ChannelsScreen />);

    expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument();
    expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
    expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
    expect(screen.getByText('News 24')).toBeInTheDocument();
    expect(screen.getByText('Off air')).toBeInTheDocument();
    expect(screen.getByTitle('Disabled')).toHaveTextContent('D');
    expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H');
    expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
    expect(screen.getByText('2 of 2 channels')).toBeInTheDocument();
    expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
  });

  // #72/#415: a channel with no playout can never play. This is the fault state the issue calls
  // "the important one" — it must be visible without hovering. The rollup filter is now "Problems"
  // (server-derived health.status), but the per-row badge still spells out the specific fault.
  it('flags a channel with no playout and lets the lineup be filtered down to the faults', async () => {
    mockApi({
      channels: [
        channelRow({ id: 1, name: 'Healthy', number: '1', playoutCount: 1 }),
        channelRow({
          id: 2,
          name: 'Broken',
          number: '2',
          playoutCount: 0,
          health: { status: 'Problems', faults: ['NoPlayout'], playoutCount: 0, brokenSourceItemCount: 0 }
        })
      ]
    });

    render(<ChannelsScreen />);

    const table = await screen.findByRole('table', { name: 'Channels lineup' });
    // Scoped to the table: the filter button carries a different label ("Problems"), so an unscoped
    // getByText would still be unambiguous, but scoping keeps this test symmetric with the others.
    expect(within(table).getByText('No playout')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Problems 1' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Problems 1' }));

    expect(screen.getByText('Broken')).toBeInTheDocument();
    expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
  });

  it('does not flag a mirror channel that relays its source playouts', async () => {
    // The server folds the mirror source's playouts into playoutCount, so a working mirror reports
    // a non-zero count and must not be flagged. Guards the mirror half of the API contract.
    mockApi({
      channels: [channelRow({ id: 1, name: 'Mirror Of One', number: '9', playoutCount: 1 })]
    });

    render(<ChannelsScreen />);

    const table = await screen.findByRole('table', { name: 'Channels lineup' });
    // Scoped: the filter button always renders this label, so an unscoped query would find it
    // even when no row is flagged, making the assertion meaningless.
    expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument();
  });

  // #415: the Problems rollup covers the full server-derived fault taxonomy, not just NoPlayout.
  it('flags channels with problems and filters the lineup down to them', async () => {
    mockApi({
      channels: [
        channelRow({ id: 1, name: 'Healthy', number: '1',
          health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } }),
        channelRow({ id: 2, name: 'Empty', number: '2', playoutCount: 1,
          health: { status: 'Problems', faults: ['EmptyUpcoming'], playoutCount: 1, brokenSourceItemCount: 0 } }),
        channelRow({ id: 3, name: 'Dead', number: '3', playoutCount: 0,
          health: { status: 'Problems', faults: ['NoPlayout'], playoutCount: 0, brokenSourceItemCount: 0 } })
      ]
    });

    render(<ChannelsScreen />);
    const table = await screen.findByRole('table', { name: 'Channels lineup' });
    expect(within(table).getByText('Empty schedule')).toBeInTheDocument();
    expect(within(table).getByText('No playout')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Problems 2' }));
    expect(screen.getByText('Empty')).toBeInTheDocument();
    expect(screen.getByText('Dead')).toBeInTheDocument();
    expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
  });

  // #415: 'Unknown' health (e.g. OnDemand channels the health check can't evaluate yet) must not be
  // mistaken for a proven fault — no badge, and it doesn't count toward "Problems".
  it('does not render a problem badge for an unknown-health channel', async () => {
    mockApi({
      channels: [channelRow({ id: 1, name: 'OnDemandIdle', number: '1',
        health: { status: 'Unknown', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } })]
    });
    render(<ChannelsScreen />);
    const table = await screen.findByRole('table', { name: 'Channels lineup' });
    expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument();
  });

  it('badges an auto-tuned channel and leaves user-created and legacy rows unmarked', async () => {
    // origin is immutable creation-provenance: AutoTuned is badged, UserCreated is not, and a legacy
    // row (origin omitted / 'Unknown') is deliberately left unmarked rather than asserted user-created.
    mockApi({
      channels: [
        channelRow({ id: 1, name: 'Generated One', number: '1', origin: 'AutoTuned' }),
        channelRow({ id: 2, name: 'Hand Made', number: '2', origin: 'UserCreated' }),
        channelRow({ id: 3, name: 'Legacy', number: '3', origin: 'Unknown' })
      ]
    });

    render(<ChannelsScreen />);

    const table = await screen.findByRole('table', { name: 'Channels lineup' });
    // Exactly one Auto-tuned badge, on the generated row only.
    expect(within(table).getAllByText('Auto-tuned')).toHaveLength(1);
  });

  it('polls only channel state after the initial load', async () => {
    const intervalHandlers: Array<() => void> = [];
    vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
      if (typeof handler === 'function') {
        intervalHandlers.push(handler as () => void);
      }

      return 1;
    });
    vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
    const handle = mockApi({
      channels: [channelRow({ streamingMode: 'HLS Segmenter' })],
      channelStates: [stateRow({ onAir: true })]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    const channelFetchesBeforePoll = fetchCount(handle, '/api/v1/channels');
    const stateFetchesBeforePoll = fetchCount(handle, '/api/v1/channels/state');

    intervalHandlers.forEach((handler) => handler());

    await waitFor(() => {
      expect(fetchCount(handle, '/api/v1/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
    });
    expect(fetchCount(handle, '/api/v1/channels')).toBe(channelFetchesBeforePoll);
  });

  it('keeps preview disabled when the fixture defaults to Unavailable', async () => {
    mockApi({
      channels: [channelRow({ streamingMode: 'HLS Segmenter' })],
      channelStates: [stateRow({ onAir: true })]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled();
  });

  it('enables preview for an available channel and opens the panel', async () => {
    mockApi({
      channels: [
        channelRow({
          name: 'Vaporwave',
          preview: { availability: 'Available', manifestUrl: '/iptv/channel/12.1.m3u8', unavailableReason: null }
        })
      ]
    });

    render(<ChannelsScreen />);

    const play = await screen.findByRole('button', { name: 'Preview Vaporwave' });
    expect(play).toBeEnabled();

    fireEvent.click(play);

    expect(await screen.findByText('Preview: Vaporwave')).toBeInTheDocument();
  });

  it('disables preview and explains why when the server says unavailable', async () => {
    mockApi({
      channels: [
        channelRow({
          name: 'Vaporwave',
          preview: {
            availability: 'Unavailable',
            manifestUrl: null,
            unavailableReason: 'Channel has no playout'
          }
        })
      ]
    });

    render(<ChannelsScreen />);

    const play = await screen.findByRole('button', { name: /Channel has no playout/ });
    expect(play).toBeDisabled();
  });

  it('offers preview for a transport-stream channel via the forced-hls path', async () => {
    mockApi({
      channels: [
        channelRow({
          name: 'Retro TV',
          preview: {
            availability: 'ForcedHlsOnly',
            manifestUrl: '/iptv/channel/12.2.m3u8?mode=segmenter',
            unavailableReason: null
          },
          streamingMode: 'MPEG-TS'
        })
      ]
    });

    render(<ChannelsScreen />);

    const play = await screen.findByRole('button', { name: 'Preview Retro TV' });
    expect(play).toBeEnabled();
  });
});

describe('ChannelsScreen — filtering + selection', () => {
  it('filters by on-air and disabled rows', async () => {
    mockApi({
      channels: [
        channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
        channelRow({
          ffmpegProfile: 'MPEG-TS',
          group: 'News',
          id: 2,
          isEnabled: false,
          language: 'fr',
          name: 'News 24',
          number: '24',
          showInEpg: false,
          sortNumber: 24,
          streamingMode: 'MPEG-TS'
        })
      ],
      channelStates: [
        stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }),
        stateRow({ channelId: 2, channelNumber: '24', onAir: false })
      ]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
    expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
    expect(screen.queryByText('News 24')).not.toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' }));
    expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument();
    expect(screen.getByText('News 24')).toBeInTheDocument();
  });

  it('clears the selection when the channel view filter changes', async () => {
    mockApi({
      channels: [
        channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
        channelRow({
          ffmpegProfile: 'MPEG-TS',
          group: 'News',
          id: 2,
          isEnabled: true,
          language: 'fr',
          name: 'News 24',
          number: '24',
          showInEpg: true,
          sortNumber: 24,
          streamingMode: 'MPEG-TS'
        })
      ],
      channelStates: [
        stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }),
        stateRow({ channelId: 2, channelNumber: '24', onAir: false })
      ]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
    fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
    expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
    expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
    expect(screen.queryByText('News 24')).not.toBeInTheDocument();

    fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
    fireEvent.click(screen.getByRole('button', { name: 'Clear' }));

    expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
  });
});

describe('ChannelsScreen — mutations', () => {
  it('runs channel row delete and refetches', async () => {
    const handle = mockApi({ channels: [channelRow()] });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    const channelFetchesBeforeDelete = fetchCount(handle, '/api/v1/channels');
    fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' }));

    await screen.findByText('Retro Cartoons');
    expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/1', expect.objectContaining({ method: 'DELETE' }));
    await waitFor(() => expect(fetchCount(handle, '/api/v1/channels')).toBeGreaterThan(channelFetchesBeforeDelete));
  });

  it('runs bulk move then surfaces a ProblemDetails error on bulk delete', async () => {
    vi.spyOn(window, 'prompt').mockReturnValue('Movies');
    const handle = mockApi({
      channels: [channelRow()],
      mutationFailures: {
        '/api/v1/channels/bulk/delete': {
          detail: 'Channel 1 cannot be deleted while active',
          status: 422,
          title: 'Validation failed'
        }
      }
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
    fireEvent.click(screen.getByRole('button', { name: 'Move to group' }));

    expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/bulk/group', expect.objectContaining({
      body: JSON.stringify({ channelIds: [1], group: 'Movies' }),
      method: 'POST'
    }));

    expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
      'aria-checked',
      'false'
    );
    fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
    fireEvent.click(screen.getByRole('button', { name: 'Delete selected' }));

    expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument();
  });

  it('runs bulk renumber and clears the selection afterward', async () => {
    vi.spyOn(window, 'prompt').mockReturnValue('10');
    const handle = mockApi({
      channels: [
        channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
        channelRow({
          ffmpegProfile: 'MPEG-TS',
          group: 'News',
          id: 2,
          isEnabled: false,
          language: 'fr',
          name: 'News 24',
          number: '24',
          showInEpg: false,
          sortNumber: 24,
          streamingMode: 'MPEG-TS'
        })
      ]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
    fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
    fireEvent.click(screen.getByRole('button', { name: 'Renumber' }));

    expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/bulk/renumber', expect.objectContaining({
      body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }),
      method: 'POST'
    }));

    expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
      'aria-checked',
      'false'
    );
    expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute(
      'aria-checked',
      'false'
    );
  });
});

describe('ChannelsScreen — bare create', () => {
  it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => {
    const handle = mockApi({
      channels: [
        channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
        channelRow({
          ffmpegProfile: 'MPEG-TS',
          group: 'News',
          id: 2,
          isEnabled: true,
          language: 'fr',
          name: 'News 24',
          number: '24',
          showInEpg: false,
          sortNumber: 24,
          streamingMode: 'MPEG-TS'
        })
      ],
      createChannelResponse: { id: 42 },
      ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 7 })
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    await waitFor(() =>
      expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
    );

    const body = requestBodyFor(handle, '/api/v1/channels');
    expect(body).toMatchObject({
      categories: null,
      fallbackFillerId: null,
      ffmpegProfileId: 7,
      group: 'ErsatzTV',
      idleBehavior: 'StopOnDisconnect',
      isEnabled: true,
      logo: { path: null, contentType: null },
      mirrorSourceChannelId: null,
      musicVideoCreditsMode: 'None',
      musicVideoCreditsTemplate: null,
      name: 'New Channel',
      number: '25',
      playoutMode: 'Continuous',
      playoutOffset: null,
      playoutSource: 'Generated',
      preferredAudioLanguageCode: null,
      preferredAudioTitle: null,
      preferredSubtitleLanguageCode: null,
      showInEpg: true,
      slugSeconds: null,
      songVideoMode: 'Default',
      streamingMode: 'TransportStreamHybrid',
      streamSelector: null,
      streamSelectorMode: 'Default',
      subtitleMode: 'None',
      transcodeMode: 'OnDemand',
      watermarkId: null
    });

    await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
  });

  it('surfaces a 422 error from bare-channel create without navigating', async () => {
    mockApi({
      channels: [channelRow()],
      createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' }
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument();
    expect(window.location.pathname).toBe('/app/channels');
  });

  it('bare-creates the first channel from an empty lineup (#212 regression)', async () => {
    // Fresh install: GET /api/v1/channels returns []. The empty state must still offer bare-create — the
    // old early return rendered a dead-end card before the action bar, so no channel could be created.
    const handle = mockApi({
      channels: [],
      createChannelResponse: { id: 7 },
      ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 3 })
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('No channels yet')).toBeInTheDocument();

    // Both create affordances are reachable on an empty lineup.
    expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    await waitFor(() =>
      expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
    );

    const body = requestBodyFor(handle, '/api/v1/channels');
    expect(body).toMatchObject({
      ffmpegProfileId: 3,
      group: 'ErsatzTV',
      name: 'New Channel',
      // max(number)+1 degenerates to 1 on an empty lineup.
      number: '1'
    });

    await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7'));
  });

  it('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => {
    // The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so
    // bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner
    // that just re-enables. (Regression for the empty branch dropping mutationError.)
    mockApi({
      channels: [],
      createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' },
      ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 0 })
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('No channels yet')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument();
    // Still on the empty lineup — no navigation, and the create affordances remain reachable.
    expect(window.location.pathname).toBe('/app/channels');
    expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
  });

  // #67: one uploaded channel logo drives both the guide listing and the on-screen bug. A newly
  // created channel should default to that shared preset rather than starting with no watermark.
  it('defaults a newly created channel to the logo-bug watermark preset', async () => {
    const handle = mockApi({
      channels: [channelRow()],
      createChannelResponse: { id: 42 },
      watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }]
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    await waitFor(() =>
      expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
    );

    const body = requestBodyFor(handle, '/api/v1/channels');
    expect(body).toMatchObject({ watermarkId: 9 });
  });

  // A watermark-list failure must degrade to no default, never block channel creation.
  it('still creates the channel when the watermark lookup fails', async () => {
    const handle = mockApi({
      channels: [channelRow()],
      createChannelResponse: { id: 42 },
      watermarksFail: true
    });

    render(<ChannelsScreen />);
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));

    await waitFor(() =>
      expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
    );

    const body = requestBodyFor(handle, '/api/v1/channels');
    expect(body).toMatchObject({ watermarkId: null });
    await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
  });
});
