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

// hls.js is mocked wholesale so jsdom never touches a real MediaSource. The shared spies let tests
// assert the manifest URL passed to loadSource.
const hlsMock = vi.hoisted(() => ({
  loadSource: vi.fn(),
  attachMedia: vi.fn(),
  on: vi.fn(),
  destroy: vi.fn(),
  isSupported: true
}));

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

// onPlay resolves the manifest through withIptvToken (#552, appends a token under a JWT-enabled
// deployment); identity-mock it so the assertions below see the plain LIVE_MANIFEST and no real
// /api/v1/auth/iptv-token fetch fires. Append behavior is covered in media/iptvToken.test.ts.
vi.mock('../media/iptvToken', () => ({
  withIptvToken: (url: string) => Promise.resolve(url),
  resetIptvTokenCache: () => {}
}));

interface Status {
  state: string;
  exitCode: null | number;
  speed: null | number;
  logs: null | string;
}

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

const IDLE: Status = { state: 'idle', exitCode: null, speed: null, logs: null };

// The open `/iptv` live manifest the start endpoint returns (was the manifest GET's own URL pre-#295).
const LIVE_MANIFEST = '/iptv/session/.troubleshooting/live.m3u8';

// Engine-neutral fake: a real `new Response(new Blob(...))` fails in CI (undici) because a jsdom
// Blob has no `.stream()` for undici's Response body ("object.stream is not a function"), which
// rejects `response.blob()` so the download never clicks. A plain object with an async `blob()`
// avoids the Blob→Response interop crossing; `URL.createObjectURL` is stubbed so the body is inert.
function zipResponse(): Response {
  return {
    ok: true,
    status: 200,
    headers: new Headers({
      'Content-Type': 'application/zip',
      'Content-Disposition': 'attachment; filename="out.zip"'
    }),
    blob: async () => new Blob(['zip-bytes'])
  } as unknown as Response;
}

// A fetch stub keyed by URL. `statusRef.current` lets a test flip the polled status over time.
// `startBody` captures the POSTed start-playback body; `startStatus` lets a test force a start error.
function installFetch(
  statusRef: { current: Status },
  options: { startStatus?: number; startBody?: { current: unknown }; startPending?: boolean } = {}
) {
  return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
    const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;

    if (url === '/api/v1/troubleshoot/playback/start') {
      if (options.startBody) {
        options.startBody.current = init?.body ? JSON.parse(String(init.body)) : null;
      }
      if (options.startStatus && options.startStatus >= 400) {
        return Promise.resolve(
          new Response(JSON.stringify({ status: options.startStatus, title: 'Conflict', detail: 'Another session is running.' }), {
            headers: { 'Content-Type': 'application/problem+json' },
            status: options.startStatus
          })
        );
      }
      // Simulate a slow server that blocks past the starting timeout (never returns segments) — the
      // only path that keeps the screen in the 'starting' phase now that a 200 promotes to running.
      if (options.startPending) {
        return new Promise<Response>(() => {});
      }
      return Promise.resolve(json({ url: LIVE_MANIFEST }));
    }
    if (url === '/api/v1/troubleshoot/playback/archive' || url.startsWith('/api/v1/troubleshoot/playback/sample/')) {
      return Promise.resolve(zipResponse());
    }

    if (url.startsWith('/api/v1/ffmpeg/profiles')) {
      return Promise.resolve(json([{ id: 1, name: 'Default' }, { id: 2, name: 'Hardware' }]));
    }
    if (url.startsWith('/api/v1/troubleshoot/playback/stream-selectors')) {
      return Promise.resolve(json(['selector-a', 'selector-b']));
    }
    if (url.startsWith('/api/v1/watermarks')) {
      return Promise.resolve(json([{ id: 10, name: 'Logo Watermark' }]));
    }
    if (url.startsWith('/api/v1/graphics-elements')) {
      return Promise.resolve(json([{ id: 20, name: 'Lower Third' }]));
    }
    if (url.includes('/api/v1/troubleshoot/playback/subtitles/')) {
      return Promise.resolve(json([{ id: 3, language: 'eng', title: 'English', codec: 'subrip' }]));
    }
    if (url.startsWith('/api/v1/troubleshoot/playback/status')) {
      return Promise.resolve(json(statusRef.current));
    }
    if (url.includes('/api/v1/media-items/')) {
      return Promise.resolve(
        json({ id: 5, title: 'Test Movie', kind: 'Movie', duration: '00:40:00', streams: [], chapters: [] })
      );
    }
    if (url.startsWith('/api/v1/channels/')) {
      // Pin the runtime shape: the server serializes with Newtonsoft, so the profile key is
      // "ffmpegProfileId". Since #198 the spec generator mirrors the runtime contract resolver, so
      // the generated types now agree ("ffmpegProfileId"); this mock stays runtime-shaped to guard
      // the fix. streamSelectorMode/streamSelector match too (no leading acronym).
      return Promise.resolve(
        json({ id: 7, name: 'Channel Seven', ffmpegProfileId: 2, streamSelectorMode: 'Default', streamSelector: null })
      );
    }
    return Promise.resolve(json({}, 404));
  });
}

function setLocation(searchAndPath: string) {
  window.history.replaceState({}, '', searchAndPath);
}

describe('PlaybackTroubleshootingScreen', () => {
  beforeEach(() => {
    vi.restoreAllMocks();
    hlsMock.loadSource.mockClear();
    hlsMock.attachMedia.mockClear();
    hlsMock.on.mockClear();
    hlsMock.destroy.mockClear();
    hlsMock.isSupported = true;
  });

  afterEach(() => {
    cleanup();
    setLocation('/app/troubleshooting/playback');
  });

  it('shows a friendly empty state when no channel or media item is supplied', async () => {
    setLocation('/app/troubleshooting/playback');
    const statusRef = { current: IDLE };
    installFetch(statusRef);

    render(<PlaybackTroubleshootingScreen />);

    expect(
      await screen.findByText(/Playback troubleshooting starts from a channel or a media item/i)
    ).toBeTruthy();
  });

  it('renders media-mode pickers and gates subtitle vs stream-selector mutual exclusion', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    installFetch(statusRef);

    render(<PlaybackTroubleshootingScreen />);

    expect(await screen.findByText(/Movie Settings — Test Movie/i)).toBeTruthy();

    // Media-only fields present.
    expect(screen.getByText('Subtitle')).toBeTruthy();
    expect(screen.getByText('Watermarks')).toBeTruthy();
    expect(screen.getByText('Graphics Elements')).toBeTruthy();
    expect(screen.getByText('Seek Seconds')).toBeTruthy();
    // Channel-only field absent.
    expect(screen.queryByText('Date and Time')).toBeNull();

    // Choosing a stream selector disables the subtitle select (mutual exclusion).
    const [, streamSelectorSelect] = screen.getAllByRole('combobox');
    const subtitleSelect = screen.getAllByRole('combobox')[2];
    expect((subtitleSelect as HTMLSelectElement).disabled).toBe(false);
    fireEvent.change(streamSelectorSelect, { target: { value: 'selector-a' } });
    await waitFor(() => expect((screen.getAllByRole('combobox')[2] as HTMLSelectElement).disabled).toBe(true));
  });

  it('renders channel mode with the channel profile preselected and hides media-only fields', async () => {
    setLocation('/app/troubleshooting/playback?channel=7');
    const statusRef = { current: IDLE };
    installFetch(statusRef);

    render(<PlaybackTroubleshootingScreen />);

    expect(await screen.findByText(/Channel Settings — Channel Seven/i)).toBeTruthy();

    // Channel's FFmpeg profile (id 2 = Hardware) is preselected.
    const profileSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
    expect(profileSelect.value).toBe('2');

    // Channel-only field present; media-only fields absent.
    expect(screen.getByText('Date and Time')).toBeTruthy();
    expect(screen.queryByText('Subtitle')).toBeNull();
    expect(screen.queryByText('Seek Seconds')).toBeNull();
  });

  it('sends the channel profile id (not 0) in the channel-mode start body', async () => {
    setLocation('/app/troubleshooting/playback?channel=7');
    const statusRef = { current: IDLE };
    const startBody = { current: null as unknown };
    installFetch(statusRef, { startBody });

    render(<PlaybackTroubleshootingScreen />);
    await screen.findByText(/Channel Settings — Channel Seven/i);

    // Play stays gated until a start datetime is chosen.
    expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true);

    const dateTimeInput = document.querySelector('input[type="datetime-local"]');
    expect(dateTimeInput).not.toBeNull();
    fireEvent.change(dateTimeInput as HTMLInputElement, { target: { value: '2026-07-09T20:00' } });

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

    // The start endpoint returns the live manifest, which is fed to hls.js.
    await waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledWith(LIVE_MANIFEST));

    // The regression this pins: the runtime channel JSON key is ffmpegProfileId (#198). Before the
    // spec was aligned, reading the spec-cased fFmpegProfileId left the profile null and the body
    // fell back to ffmpegProfile=0.
    expect(startBody.current).toMatchObject({
      ffmpegProfile: 2,
      channel: 7,
      mediaItem: 0,
      streamingMode: 'HttpLiveStreamingSegmenter',
      start: new Date('2026-07-09T20:00').toISOString()
    });
  });

  it('POSTs the correct start body on Play, feeds the returned manifest to hls.js, and starts polling', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    const startBody = { current: null as unknown };
    installFetch(statusRef, { startBody });
    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

      // After Play, the status endpoint reports running.
      statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };

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

      // The POST resolves with the live manifest, which is handed to hls.js.
      await vi.waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledWith(LIVE_MANIFEST));

      expect(startBody.current).toMatchObject({
        ffmpegProfile: 1,
        streamingMode: 'HttpLiveStreamingSegmenter',
        mediaItem: 5,
        channel: 0,
        seekSeconds: 1200 // 40 min / 2
      });

      // Polling picks up the running status → Play becomes disabled.
      await vi.advanceTimersByTimeAsync(2000);
      await vi.waitFor(() => expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true));
    } finally {
      vi.useRealTimers();
    }
  });

  it('surfaces the ProblemDetails detail and returns to idle when start fails (409)', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    installFetch(statusRef, { startStatus: 409 });

    render(<PlaybackTroubleshootingScreen />);
    await screen.findByText(/Movie Settings/i);

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

    // 409 detail surfaces via the notice, and Play returns to the idle (enabled) phase.
    await waitFor(() => expect(screen.getByText('Another session is running.')).toBeTruthy());
    await waitFor(() => expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false));
    expect(hlsMock.loadSource).not.toHaveBeenCalled();
  });

  it('surfaces logs and a speed badge when polling transitions running → completed', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    installFetch(statusRef);
    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

      statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
      fireEvent.click(screen.getByRole('button', { name: 'Play' }));

      await vi.advanceTimersByTimeAsync(2000);
      await vi.waitFor(() => expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true));

      // Flip to completed with logs + speed.
      statusRef.current = { state: 'completed', exitCode: 0, speed: 1.5, logs: 'ffmpeg log line' };
      await vi.advanceTimersByTimeAsync(2000);

      await vi.waitFor(() => expect(screen.getByText(/Speed: 1.5x/)).toBeTruthy());
      expect((screen.getByDisplayValue('ffmpeg log line') as HTMLTextAreaElement).value).toBe('ffmpeg log line');
      // Success notice + Play re-enabled.
      expect(screen.getByText(/exited successfully/i)).toBeTruthy();
      expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
    } finally {
      vi.useRealTimers();
    }
  });

  it('re-issues the manifest request on a repeat Play with unchanged settings', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    installFetch(statusRef);
    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

      // First play through to completion. The start POST resolves with the constant live manifest.
      statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
      fireEvent.click(screen.getByRole('button', { name: 'Play' }));
      await vi.waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledTimes(1));
      expect(hlsMock.loadSource.mock.calls[0][0]).toBe(LIVE_MANIFEST);

      await vi.advanceTimersByTimeAsync(2000); // observe running
      statusRef.current = { state: 'completed', exitCode: 0, speed: 1.1, logs: 'done' };
      await vi.advanceTimersByTimeAsync(2000); // observe completed
      await vi.waitFor(() =>
        expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false)
      );

      // Repeat Play with untouched settings: the manifest URL is identical, but HlsPlayer MUST
      // re-attach (the playToken bump) so the returned live manifest is re-requested.
      statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
      fireEvent.click(screen.getByRole('button', { name: 'Play' }));
      await vi.waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledTimes(2));
      expect(hlsMock.loadSource.mock.calls[1][0]).toBe(hlsMock.loadSource.mock.calls[0][0]);
      expect(hlsMock.destroy).toHaveBeenCalled(); // previous instance torn down before re-attach
    } finally {
      vi.useRealTimers();
    }
  });

  it('promotes to running on the start POST 200 and settles a fast-completing clip (Codex race fix)', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    // PrepareTroubleshootingPlayback calls statusStore.Reset() server-side and the start POST returns
    // 200 only after segments exist, so any settled status seen afterward is THIS session's. A short
    // clip can settle between polls without any poll ever observing 'running' — it must still settle.
    const statusRef = { current: IDLE };
    installFetch(statusRef);
    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

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

      // 'starting': Play disabled immediately, indicator shown.
      expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true);
      expect(screen.getByText('Starting…')).toBeTruthy();

      // The start POST resolves 200 → phase promotes 'starting' → 'running' even though no poll has
      // observed a 'running' status (statusRef is still IDLE), proving the promotion came from the POST.
      await vi.advanceTimersByTimeAsync(0);
      await vi.waitFor(() => expect(screen.getByText('Transcoding…')).toBeTruthy());

      // The clip completed fast: the very next poll returns 'completed' and it is ACCEPTED (previously
      // discarded as a stale result), so the toast + new logs appear and Play re-enables.
      statusRef.current = { state: 'completed', exitCode: 0, speed: 1.2, logs: 'new logs' };
      await vi.advanceTimersByTimeAsync(2000);
      await vi.waitFor(() => expect(screen.getByText(/exited successfully/i)).toBeTruthy());
      expect(screen.getByDisplayValue('new logs')).toBeTruthy();
      expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
    } finally {
      vi.useRealTimers();
    }
  });

  it('gives up with an error when the start POST hangs and the session never reports running', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    // The start POST never resolves (slow server), so the only exit from 'starting' is the timeout —
    // polls keep returning idle and no 200 ever promotes to running.
    installFetch(statusRef, { startPending: true });
    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

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

      // Stays in 'starting' past the 30s timeout → error notice, Play re-enabled.
      await vi.advanceTimersByTimeAsync(32_000);
      await vi.waitFor(() => expect(screen.getByText(/did not start within 30 seconds/i)).toBeTruthy());
      expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
    } finally {
      vi.useRealTimers();
    }
  });

  it('downloads the archive and media sample via a POST-blob fetch (never a browser tab), gated while active', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5');
    const statusRef = { current: IDLE };
    const fetchSpy = installFetch(statusRef);
    // Never open a download endpoint in a browser tab.
    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
    // jsdom lacks these on URL — stub the object-URL/anchor-click download path.
    (URL as unknown as { createObjectURL: unknown }).createObjectURL = vi.fn(() => 'blob:mock');
    (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn();
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});

    const postCall = (path: string) =>
      fetchSpy.mock.calls.find(
        (call) => call[0] === path && (call[1]?.method ?? 'GET').toUpperCase() === 'POST'
      );

    vi.useFakeTimers();

    try {
      render(<PlaybackTroubleshootingScreen />);
      await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());

      // Download Media Sample is available while idle → POST-blob, not window.open.
      fireEvent.click(screen.getByRole('button', { name: /Download Media Sample/i }));
      await vi.waitFor(() => expect(postCall('/api/v1/troubleshoot/playback/sample/5')).toBeTruthy());
      expect(openSpy).not.toHaveBeenCalled();

      // Both downloads are gated while a session is starting/running.
      statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
      fireEvent.click(screen.getByRole('button', { name: 'Play' }));
      expect((screen.getByRole('button', { name: /Download Results/i }) as HTMLButtonElement).disabled).toBe(true);
      expect((screen.getByRole('button', { name: /Download Media Sample/i }) as HTMLButtonElement).disabled).toBe(true);

      // Drive to completion so Download Results enables (hasPlayed && not busy).
      await vi.advanceTimersByTimeAsync(2000); // observe running
      statusRef.current = { state: 'completed', exitCode: 0, speed: 1, logs: 'done' };
      await vi.advanceTimersByTimeAsync(2000); // observe completed
      await vi.waitFor(() =>
        expect((screen.getByRole('button', { name: /Download Results/i }) as HTMLButtonElement).disabled).toBe(false)
      );

      fireEvent.click(screen.getByRole('button', { name: /Download Results/i }));
      await vi.waitFor(() => expect(postCall('/api/v1/troubleshoot/playback/archive')).toBeTruthy());
      expect(clickSpy).toHaveBeenCalled();
      expect(openSpy).not.toHaveBeenCalled();
    } finally {
      vi.useRealTimers();
    }
  });

  it('uses media mode when both mediaItem and channel params are present', async () => {
    setLocation('/app/troubleshooting/playback?mediaItem=5&channel=7');
    const statusRef = { current: IDLE };
    installFetch(statusRef);

    render(<PlaybackTroubleshootingScreen />);

    expect(await screen.findByText(/Movie Settings — Test Movie/i)).toBeTruthy();
    expect(screen.queryByText('Date and Time')).toBeNull();
  });
});
