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

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

// PlayoutListItemResponseModel: rail rows have no detail-only fields.
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    buildStatus: {
      lastBuild: '2026-07-05T19:00:00Z',
      message: null,
      success: true
    },
    channelId: 42,
    channelName: 'Retro Cartoons',
    channelNumber: '5.1',
    dailyRebuildTime: '04:00:00',
    id: 20,
    isLocked: false,
    scheduleKind: 'Classic',
    scheduleName: 'Prime Time Cartoons',
    seed: 0,
    ...overrides
  };
}

// PlayoutResponseModel adds detail-only fields to the list row.
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return listPlayout({
    playoutMode: 'Continuous',
    scheduleFile: null,
    seed: 0,
    ...overrides
  });
}

// The backend always emits fillerKind; real content carries None.
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    duration: '00:30:00',
    fillerKind: 'None',
    finish: '2026-07-05T20:30:00Z',
    start: '2026-07-05T20:00:00Z',
    title: 'Saturday Morning Cartoons',
    ...overrides
  };
}

function schedule(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    fixedStartTimeBehavior: 'Flexible',
    id: 1,
    keepMultiPartEpisodesTogether: true,
    name: 'Default Schedule',
    randomStartPoint: false,
    shuffleScheduleItems: false,
    treatCollectionsAsShows: false,
    ...overrides
  };
}

function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    categories: '',
    ffmpegProfile: '1080p H.264',
    group: 'ChicoryTV',
    id: 1,
    isEnabled: true,
    language: 'English',
    name: 'Movies',
    number: '5',
    showInEpg: true,
    sortNumber: 5,
    streamingMode: 'HttpLiveStreamingSegmenter',
    ...overrides
  };
}

interface Problem {
  detail?: string;
  status?: number;
  title?: string;
}

interface MockOptions {
  channels?: unknown[];
  channelStates?: unknown[];
  confirm?: boolean;
  createPlayoutResponse?: unknown;
  decos?: unknown[];
  mutationFailures?: Record<string, Problem>;
  playoutDetails?: unknown;
  playoutItems?: unknown[];
  playoutItemsFailure?: Problem | null;
  playoutItemsFailuresBeforeSuccess?: number;
  playoutItemsTotalCount?: number | null;
  playoutSchedulingContext?: string;
  playoutSchedulingContextFailure?: Problem | null;
  playoutWarningsCount?: number;
  playouts?: unknown;
  schedules?: unknown[];
  updatePlayoutDetailsResponse?: unknown;
}

function mockApi(options: MockOptions = {}): void {
  const channels = options.channels ?? [];
  const channelStates = options.channelStates ?? [];
  const decos = options.decos ?? [];
  const mutationFailures = options.mutationFailures ?? {};
  const playoutDetails = options.playoutDetails ?? null;
  const playoutItems = options.playoutItems ?? [];
  const playoutSchedulingContext = options.playoutSchedulingContext ?? '{\n  "sample": true\n}';
  const playoutWarningsCount = options.playoutWarningsCount ?? 0;
  const playouts = options.playouts ?? { page: [], totalCount: 0 };
  const schedules = options.schedules ?? [];
  let remainingPlayoutItemsFailures = options.playoutItemsFailuresBeforeSuccess ?? 0;

  vi.spyOn(window, 'confirm').mockReturnValue(options.confirm ?? false);
  vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
    const path = input.toString();
    const method = (init?.method ?? 'GET').toUpperCase();

    if (path === '/api/v1/channels') {
      return Promise.resolve(jsonResponse(channels));
    }

    if (path === '/api/v1/channels/state') {
      return Promise.resolve(jsonResponse(channelStates));
    }

    if (path === '/api/v1/schedules') {
      return Promise.resolve(jsonResponse(schedules));
    }

    if (path === '/api/v1/decos') {
      return Promise.resolve(jsonResponse(decos));
    }

    if (path === '/api/v1/playouts') {
      if (method === 'POST') {
        const failure = mutationFailures[path];
        if (failure) {
          return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
        }

        return Promise.resolve(jsonResponse(options.createPlayoutResponse ?? playout({ id: 99 }), 201));
      }

      return Promise.resolve(jsonResponse(playouts));
    }

    if (path === '/api/v1/playouts/warnings/count') {
      return Promise.resolve(jsonResponse(playoutWarningsCount));
    }

    if (path === '/api/v1/playouts/reset-all') {
      const failure = mutationFailures[path];
      if (failure) {
        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
      }

      return Promise.resolve(new Response(null, { status: 202 }));
    }

    if (/^\/api\/v1\/channels\/\d+\/playout\/reset$/.test(path)) {
      const failure = mutationFailures[path];
      if (failure) {
        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
      }

      return Promise.resolve(new Response(null, { status: 204 }));
    }

    if (/^\/api\/v1\/playouts\/\d+\/erase-items(-and-history)?$/.test(path)) {
      const failure = mutationFailures[path];
      if (failure) {
        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
      }

      return Promise.resolve(new Response(null, { status: 204 }));
    }

    if (/^\/api\/v1\/playouts\/\d+\/reshuffle$/.test(path)) {
      const failure = mutationFailures[path];
      if (failure) {
        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
      }

      return Promise.resolve(new Response(null, { status: 202 }));
    }

    if (/^\/api\/v1\/playouts\/\d+\/deco$/.test(path)) {
      const failure = mutationFailures[path];
      if (failure) {
        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
      }

      return Promise.resolve(jsonResponse(
        playoutDetails ?? playout({ id: 20, scheduleKind: 'Block' })
      ));
    }

    if (/^\/api\/v1\/playouts\/items\/\d+\/scheduling-context$/.test(path)) {
      if (options.playoutSchedulingContextFailure) {
        const failure = options.playoutSchedulingContextFailure;
        return Promise.resolve(jsonResponse(failure, failure.status ?? 404));
      }

      return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
    }

    const itemsMatch = path.match(/^\/api\/v1\/playouts\/\d+\/items(\?(?<query>.*))?$/);
    if (itemsMatch) {
      if (remainingPlayoutItemsFailures > 0) {
        remainingPlayoutItemsFailures -= 1;
        return Promise.resolve(jsonResponse(options.playoutItemsFailure, options.playoutItemsFailure?.status ?? 404));
      }

      const showFiller = new URLSearchParams(itemsMatch.groups?.query ?? '').get('showFiller') === 'true';
      const page = showFiller
        ? playoutItems
        : playoutItems.filter((item) => {
            const fillerKind = (item as { fillerKind?: null | string }).fillerKind;
            return fillerKind == null || fillerKind === 'None';
          });

      return Promise.resolve(jsonResponse({
        page,
        totalCount: options.playoutItemsTotalCount ?? page.length
      }));
    }

    if (/^\/api\/v1\/playouts\/\d+$/.test(path)) {
      const failure = mutationFailures[path];

      if (method === 'PUT') {
        if (failure) {
          return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
        }

        return Promise.resolve(jsonResponse(
          options.updatePlayoutDetailsResponse ?? playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })
        ));
      }

      if (method === 'DELETE') {
        if (failure) {
          return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
        }

        return Promise.resolve(new Response(null, { status: 204 }));
      }

      return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
    }

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

function fetchCount(path: string): number {
  return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
}

function requestBodyFor(path: string): Record<string, unknown> {
  const body = requestBodiesFor(path)[0];
  if (!body) {
    throw new Error('no request captured for ' + path);
  }

  return body;
}

function requestBodiesFor(path: string): Array<Record<string, unknown>> {
  return vi
    .mocked(window.fetch)
    .mock.calls.filter(([input, init]) => input.toString() === path && init?.body != null)
    .map(([, init]) => JSON.parse(init?.body as string) as Record<string, unknown>);
}

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

beforeEach(() => {
  window.history.replaceState(null, '', '/app/playouts');
  vi.restoreAllMocks();
});

describe('PlayoutsScreen', () => {
  it('renders the Playouts monitor from live playout and channel state APIs', async () => {
    mockApi({
      channelStates: [
        {
          channelId: 1,
          channelNumber: '5.1',
          onAir: true,
          nowPlaying: {
            finishUtc: '2026-07-05T20:30:00Z',
            startUtc: '2026-07-05T20:00:00Z',
            title: 'Saturday Morning Cartoons'
          }
        }
      ],
      playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleFile: '/config/schedules/retro.json' }),
      playoutItems: [
        playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
        playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00' }),
        playoutItem({ title: 'Moon Patrol', start: '2026-07-05T20:31:00Z', finish: '2026-07-05T21:00:00Z' })
      ],
      playoutWarningsCount: 3,
      playouts: {
        page: [
          listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }),
          listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
        ],
        totalCount: 2
      }
    });

    render(<PlayoutsScreen />);


    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    expect(screen.getByText('2 playouts loaded')).toBeInTheDocument();
    expect(screen.getByText('3 warnings')).toBeInTheDocument();
    expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
    expect(screen.getAllByText('Moon Patrol').length).toBeGreaterThan(0);
    expect(screen.getAllByText('Station ID').length).toBeGreaterThan(0);
    // Real items carry fillerKind 'None' and must not be badged as filler.
    expect(screen.queryByText('Filler')).not.toBeInTheDocument();
    expect(screen.getByText('Metadata preview only')).toBeInTheDocument();
    expect(screen.getByDisplayValue('Continuous')).toBeInTheDocument();
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20/items', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/warnings/count', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
  });

  it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
    mockApi({
      playoutItems: [
        playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
        playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00', fillerKind: 'PreRoll' })
      ],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    // Toggle off: the filler item is not requested, so no Filler badge renders.
    expect(screen.queryByText('Station ID')).not.toBeInTheDocument();
    expect(screen.queryByText('Filler')).not.toBeInTheDocument();

    fireEvent.click(screen.getByRole('switch', { name: 'Show filler' }));

    expect((await screen.findAllByText('Station ID')).length).toBeGreaterThan(0);
    expect(screen.getByText('Filler')).toBeInTheDocument();
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20/items?showFiller=true', expect.any(Object));
  });

  it('fetches the Playouts screen data exactly once on mount and only polls channel state', async () => {
    const intervalHandlers: Array<() => void> = [];
    vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
      if (typeof handler === 'function') {
        intervalHandlers.push(handler as () => void);
      }

      return intervalHandlers.length;
    });
    vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
    mockApi({
      channelStates: [
        { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }
      ],
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(fetchCount('/api/v1/playouts')).toBe(1);
    expect(fetchCount('/api/v1/playouts/20')).toBe(1);
    expect(fetchCount('/api/v1/playouts/20/items')).toBe(1);
    const playoutFetchesBeforePoll = fetchCount('/api/v1/playouts');
    const stateFetchesBeforePoll = fetchCount('/api/v1/channels/state');

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

    await waitFor(() => {
      expect(fetchCount('/api/v1/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
    });
    expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforePoll);
    expect(fetchCount('/api/v1/playouts/20/items')).toBe(1);
  });

  it('switches selected playouts and fetches only the selected playout detail and items', async () => {
    mockApi({
      playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
      playoutItems: [playoutItem({ title: 'Saturday Morning Cartoons' })],
      playouts: {
        page: [
          listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
          listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
        ],
        totalCount: 2
      }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

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

    expect(await screen.findByRole('heading', { name: 'News 24' })).toBeInTheDocument();
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/21', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/21/items', expect.any(Object));
    expect(fetchCount('/api/v1/playouts')).toBe(1);
  });

  it('shows the Playouts loading state', async () => {
    vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
      const path = input.toString();

      if (path === '/api/v1/playouts') {
        return new Promise<Response>(() => {});
      }

      if (path === '/api/v1/channels/state') {
        return Promise.resolve(jsonResponse([]));
      }

      if (path === '/api/v1/playouts/warnings/count') {
        return Promise.resolve(jsonResponse(0));
      }

      return Promise.resolve(jsonResponse([]));
    });

    render(<PlayoutsScreen />);


    expect((await screen.findAllByText('Loading playouts')).length).toBeGreaterThan(0);
  });

  it('shows Playouts API errors and retries 404 parent handling', async () => {
    mockApi({
      playoutItemsFailure: {
        detail: 'Playout 20 was not found',
        status: 404,
        title: 'Not found'
      },
      playoutItemsFailuresBeforeSuccess: 1,
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);


    expect(await screen.findByText('Playout 20 was not found')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: 'Retry' }));

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
  });

  it('shows an empty Playouts state when no playouts exist', async () => {
    mockApi({ playouts: { page: [], totalCount: 0 } });

    render(<PlayoutsScreen />);


    expect((await screen.findAllByText('No playouts returned')).length).toBeGreaterThan(0);
    expect(screen.getByRole('button', { name: 'Add Playout' })).toBeInTheDocument();
  });

  it('resets all playouts with confirmation and refetches playout-scoped data', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    const playoutFetchesBeforeReset = fetchCount('/api/v1/playouts');
    const itemFetchesBeforeReset = fetchCount('/api/v1/playouts/20/items');

    fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' }));

    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/reset-all', expect.objectContaining({ method: 'POST' }));
    });
    expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeReset + 1);
    expect(fetchCount('/api/v1/playouts/20/items')).toBe(itemFetchesBeforeReset + 1);
  });

  it('shows ProblemDetails when the reset-all transport fails with a 5xx', async () => {
    // Backend contract: POST /api/v1/playouts/reset-all is an unconditional 202 — there is no
    // application-level error path today. This test guards client robustness only, against
    // transport/server failures (proxy errors, unhandled exceptions).
    mockApi({
      confirm: true,
      mutationFailures: {
        '/api/v1/playouts/reset-all': {
          detail: 'The playout worker crashed while queueing rebuilds',
          status: 500,
          title: 'Internal Server Error'
        }
      },
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' }));

    expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
  });

  it('deletes the selected playout with confirmation and refetches', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    const playoutFetchesBeforeDelete = fetchCount('/api/v1/playouts');

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

    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20', expect.objectContaining({ method: 'DELETE' }));
    });
    expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeDelete + 1);
  });

  it('resets the selected channel playout with confirmation', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
      channelStates: [{ channelId: 7, channelNumber: '5.1', onAir: true, nowPlaying: null }],
      playouts: { page: [listPlayout({ id: 20, channelId: 7, channelNumber: '5.1' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

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

    await waitFor(() => {
      // Keyed on the channel id carried directly on the playout summary (7, #297), not the playout
      // id (20) or the channel number.
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/channels/7/playout/reset',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  it('shows both erase buttons for a Block playout and posts to the right routes', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Erase items' }));
    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/playouts/20/erase-items',
        expect.objectContaining({ method: 'POST' })
      );
    });

    fireEvent.click(screen.getByRole('button', { name: 'Erase items and history' }));
    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/playouts/20/erase-items-and-history',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  it('reshuffles a Classic playout and posts to the reshuffle route', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' }));
    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/playouts/20/reshuffle',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  it('shows the play-order seed for the selected playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic', seed: 424242 }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic', seed: 424242 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    expect(screen.getByDisplayValue('424242')).toBeInTheDocument();
  });

  it('shows only erase-items-and-history for a Classic playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
  });

  it('shows no erase buttons for an ExternalJson playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'ExternalJson' }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'ExternalJson' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.queryByRole('button', { name: 'Erase items and history' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
  });

  it.each([
    ['None', { alternate: false, defaultDeco: false, eraseHistory: false, eraseItems: false, templates: false }],
    ['Classic', { alternate: true, defaultDeco: false, eraseHistory: true, eraseItems: false, templates: false }],
    ['Block', { alternate: false, defaultDeco: true, eraseHistory: true, eraseItems: true, templates: true }],
    ['Sequential', { alternate: false, defaultDeco: false, eraseHistory: true, eraseItems: false, templates: false }],
    ['Scripted', { alternate: false, defaultDeco: false, eraseHistory: true, eraseItems: false, templates: false }],
    ['ExternalJson', { alternate: false, defaultDeco: false, eraseHistory: false, eraseItems: false, templates: false }]
  ])('shows the complete action gate matrix for %s playouts', async (scheduleKind, expected) => {
    mockApi({
      playoutDetails: playout({ id: 20, scheduleKind }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20, scheduleKind })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    expect(Boolean(screen.queryByRole('button', { name: 'Alternate schedules' }))).toBe(expected.alternate);
    expect(Boolean(screen.queryByRole('button', { name: 'Templates' }))).toBe(expected.templates);
    expect(Boolean(screen.queryByLabelText('Default deco'))).toBe(expected.defaultDeco);
    expect(Boolean(screen.queryByRole('button', { name: 'Erase items' }))).toBe(expected.eraseItems);
    expect(Boolean(screen.queryByRole('button', { name: 'Erase items and history' }))).toBe(expected.eraseHistory);
  });

  it('sets and clears a Block playout default deco and refreshes after each save', async () => {
    mockApi({
      decos: [{ decoGroupId: 2, decoGroupName: 'Prime', id: 4, name: 'Movie Night' }],
      playoutDetails: playout({ decoId: null, id: 20, scheduleKind: 'Block' }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    await screen.findByRole('option', { name: 'Prime / Movie Night' });
    const playoutFetchesBeforeSave = fetchCount('/api/v1/playouts');

    fireEvent.change(screen.getByLabelText('Default deco'), { target: { value: '4' } });

    await waitFor(() => {
      expect(requestBodyFor('/api/v1/playouts/20/deco')).toEqual({ decoId: 4 });
    });
    expect(window.fetch).toHaveBeenCalledWith(
      '/api/v1/playouts/20/deco',
      expect.objectContaining({ method: 'PUT' })
    );
    await waitFor(() => {
      expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeSave + 1);
    });

    await waitFor(() => {
      expect(screen.getByLabelText('Default deco')).toBeEnabled();
    });
    fireEvent.change(screen.getByLabelText('Default deco'), { target: { value: '' } });

    await waitFor(() => {
      expect(requestBodiesFor('/api/v1/playouts/20/deco')).toEqual([{ decoId: 4 }, { decoId: null }]);
      expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeSave + 2);
    });
  });

  it('shows a Block default-deco save error without closing the control', async () => {
    mockApi({
      decos: [{ decoGroupId: 2, decoGroupName: 'Prime', id: 4, name: 'Movie Night' }],
      mutationFailures: {
        '/api/v1/playouts/20/deco': {
          detail: 'Default deco could not be saved',
          status: 422,
          title: 'Validation failed'
        }
      },
      playoutDetails: playout({ decoId: null, id: 20, scheduleKind: 'Block' }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    await screen.findByRole('option', { name: 'Prime / Movie Night' });

    fireEvent.change(screen.getByLabelText('Default deco'), { target: { value: '4' } });

    expect(await screen.findByText('Default deco could not be saved')).toBeInTheDocument();
    expect(screen.getByLabelText('Default deco')).toBeEnabled();
  });

  it('refreshes after a 409 mutation conflict so the latest build lock can be rendered', async () => {
    mockApi({
      confirm: true,
      mutationFailures: {
        '/api/v1/playouts/20': {
          detail: 'A playout build is already in progress',
          status: 409,
          title: 'Conflict'
        }
      },
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);
    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    const playoutFetchesBeforeConflict = fetchCount('/api/v1/playouts');

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

    expect(await screen.findByText('A playout build is already in progress')).toBeInTheDocument();
    await waitFor(() => {
      expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeConflict + 1);
    });
  });

  it('disables mutation buttons and shows a Building cue for a locked (building) playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
      playouts: { page: [listPlayout({ id: 20, isLocked: true, scheduleKind: 'Block' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.getByText('Building…')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Erase items' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
  });

  it('disables Alternate schedules for an on-demand Classic playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeDisabled();
  });

  it('enables Alternate schedules for a continuous Classic playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeEnabled();
  });

  it('opens the scheduling-context dialog for an item that has one', async () => {
    mockApi({
      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
      playoutDetails: playout({ id: 20 }),
      playoutSchedulingContext: 'SCHEDULING_CONTEXT_BODY',
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));

    expect(await screen.findByText('SCHEDULING_CONTEXT_BODY')).toBeInTheDocument();
    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/playouts/items/99/scheduling-context',
        expect.any(Object)
      );
    });
  });

  it('shows an error in the scheduling-context dialog when the fetch fails', async () => {
    mockApi({
      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
      playoutDetails: playout({ id: 20 }),
      playoutSchedulingContextFailure: { detail: 'No scheduling context available', status: 404 },
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));

    expect(await screen.findByText('No scheduling context available')).toBeInTheDocument();
  });

  it('does not show a scheduling-context button for items without one', async () => {
    mockApi({
      playoutItems: [playoutItem({ hasSchedulingContext: false, id: 99 })],
      playoutDetails: playout({ id: 20 }),
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    expect(screen.queryByRole('button', { name: 'Scheduling context' })).not.toBeInTheDocument();
  });

  it('filters the playout selector rail by channel name', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playouts: {
        page: [
          listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
          listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
        ],
        totalCount: 2
      }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    const rail = screen.getByRole('complementary', { name: 'Playout selector' });
    expect(within(rail).getByRole('button', { name: /Retro Cartoons/ })).toBeInTheDocument();
    expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('Filter playouts'), { target: { value: 'News' } });

    expect(within(rail).queryByRole('button', { name: /Retro Cartoons/ })).not.toBeInTheDocument();
    expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
    expect(screen.getByText('1 of 2 playouts')).toBeInTheDocument();
  });

  it('renders honestly when totalCount exceeds the returned page length', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutItemsTotalCount: 500,
      playouts: {
        page: [
          listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
          listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
        ],
        totalCount: 150
      }
    });

    render(<PlayoutsScreen />);


    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    // Only 2 of 150 rows were returned; the count label must not claim all 150 loaded.
    expect(screen.getByText('2 of 150 playouts')).toBeInTheDocument();
    // Items page is also partial (1 of 500); the monitor must still render without crashing.
    expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
  });

  it('creates a classic playout from the Add Playout dialog and selects it', async () => {
    mockApi({
      channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
      createPlayoutResponse: playout({ id: 42 }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
      schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
    const dialog = await screen.findByRole('dialog');
    const createButton = within(dialog).getByRole('button', { name: 'Create' });
    expect(createButton).toBeDisabled();

    fireEvent.change(within(dialog).getByLabelText('Channel'), { target: { value: '1' } });
    await within(dialog).findByRole('option', { name: 'Weekend Lineup' });
    expect(createButton).toBeDisabled();
    fireEvent.change(within(dialog).getByLabelText('Classic schedule'), { target: { value: '7' } });
    expect(createButton).toBeEnabled();

    fireEvent.click(createButton);

    await waitFor(() => {
      expect(requestBodyFor('/api/v1/playouts')).toMatchObject({
        channelId: 1,
        programScheduleId: 7,
        scheduleKind: 'Classic'
      });
    });
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts', expect.objectContaining({ method: 'POST' }));
    await waitFor(() => {
      expect(screen.queryByText('Add playout')).not.toBeInTheDocument();
    });
  });

  it.each([
    ['Block', null, 'Block templates are added later.'],
    ['Sequential', 'Sequential schedule', 'The full path to the sequential schedule (YAML) file'],
    ['Scripted', 'Scripted schedule', 'The command line used to run the scripted schedule'],
    ['ExternalJson', 'JSON (dizqueTV) schedule', 'The full path to the JSON (dizqueTV) schedule file']
  ])('creates a %s playout with the correct add-dialog gates', async (scheduleKind, fileLabel, helperText) => {
    mockApi({
      channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
      createPlayoutResponse: playout({ id: 42, scheduleKind }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
    const dialog = await screen.findByRole('dialog');
    const createButton = within(dialog).getByRole('button', { name: 'Create' });
    expect(createButton).toBeDisabled();

    fireEvent.change(within(dialog).getByLabelText('Kind'), { target: { value: scheduleKind } });
    fireEvent.change(within(dialog).getByLabelText('Channel'), { target: { value: '1' } });
    expect(within(dialog).queryByLabelText('Classic schedule')).not.toBeInTheDocument();

    if (fileLabel) {
      const scheduleFile = within(dialog).getByLabelText(fileLabel);
      expect(within(dialog).getByText(helperText)).toBeInTheDocument();
      expect(createButton).toBeDisabled();
      fireEvent.change(scheduleFile, { target: { value: '  schedule-source  ' } });
    } else {
      expect(within(dialog).getByText(helperText)).toBeInTheDocument();
    }
    expect(createButton).toBeEnabled();

    fireEvent.click(createButton);

    await waitFor(() => {
      expect(requestBodyFor('/api/v1/playouts')).toEqual({
        channelId: 1,
        programScheduleId: null,
        scheduleFile: fileLabel ? 'schedule-source' : null,
        scheduleKind
      });
    });
  });

  it('shows the create-playout error inline when the API rejects the request', async () => {
    mockApi({
      channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
      mutationFailures: {
        '/api/v1/playouts': {
          detail: 'Channel already has one playout',
          status: 422,
          title: 'Validation failed'
        }
      },
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
      schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
    expect(await screen.findByText('Add playout')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
    await screen.findByRole('option', { name: 'Weekend Lineup' });
    fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });

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

    expect(await screen.findByText('Channel already has one playout')).toBeInTheDocument();
  });

  it('edits daily rebuild time from the playout detail panel', async () => {
    mockApi({
      playoutDetails: playout({ id: 20, dailyRebuildTime: '04:00:00' }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
      updatePlayoutDetailsResponse: playout({ id: 20, dailyRebuildTime: '05:00:00' })
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
    expect(await screen.findByText('Edit playout details')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('Daily reset time'), { target: { value: '05:00:00' } });
    fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));

    await waitFor(() => {
      expect(requestBodyFor('/api/v1/playouts/20')).toMatchObject({ dailyRebuildTime: '05:00:00' });
    });
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20', expect.objectContaining({ method: 'PUT' }));
    await waitFor(() => {
      expect(screen.queryByText('Edit playout details')).not.toBeInTheDocument();
    });
  });

  it.each([
    ['Classic', null],
    ['Block', null],
    ['Sequential', 'Sequential schedule'],
    ['Scripted', 'Scripted schedule'],
    ['ExternalJson', 'JSON (dizqueTV) schedule']
  ])('shows the correct edit schedule-file gate for %s playouts', async (scheduleKind, fileLabel) => {
    mockApi({
      playoutDetails: playout({ id: 20, scheduleFile: '/config/schedule.yml', scheduleKind }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20, scheduleKind })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
    const dialog = await screen.findByRole('dialog');

    const scheduleFile = within(dialog).queryByLabelText(/^(Sequential|Scripted|JSON \(dizqueTV\)) schedule$/);
    if (fileLabel) {
      const scheduleFileInput = within(dialog).getByLabelText(fileLabel);
      expect(scheduleFile).toBeInTheDocument();
      fireEvent.change(scheduleFileInput, { target: { value: '   ' } });
      expect(within(dialog).getByRole('button', { name: 'Save changes' })).toBeDisabled();
      fireEvent.change(scheduleFileInput, { target: { value: '  updated-source  ' } });
      expect(within(dialog).getByRole('button', { name: 'Save changes' })).toBeEnabled();
      const playoutFetchesBeforeSave = fetchCount('/api/v1/playouts');
      fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }));

      await waitFor(() => {
        expect(requestBodyFor('/api/v1/playouts/20')).toMatchObject({ scheduleFile: 'updated-source' });
      });
      await waitFor(() => {
        expect(fetchCount('/api/v1/playouts')).toBe(playoutFetchesBeforeSave + 1);
      });
    } else {
      expect(scheduleFile).not.toBeInTheDocument();
      expect(within(dialog).getByRole('button', { name: 'Save changes' })).toBeEnabled();
    }
  });

  it('keeps a file-backed edit dialog open and shows an inline save error', async () => {
    mockApi({
      mutationFailures: {
        '/api/v1/playouts/20': {
          detail: 'Schedule file could not be saved',
          status: 422,
          title: 'Validation failed'
        }
      },
      playoutDetails: playout({ id: 20, scheduleFile: '/config/schedule.yml', scheduleKind: 'Sequential' }),
      playoutItems: [playoutItem()],
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Sequential' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
    const dialog = await screen.findByRole('dialog');

    fireEvent.change(within(dialog).getByLabelText('Sequential schedule'), {
      target: { value: '/config/rejected.yml' }
    });
    fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }));

    expect(await within(dialog).findByText('Schedule file could not be saved')).toBeInTheDocument();
    expect(dialog).toBeInTheDocument();
    expect(within(dialog).getByLabelText('Sequential schedule')).toBeEnabled();
  });
});
