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

describe('DashboardScreen', () => {
  afterEach(() => {
    cleanup();
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  beforeEach(() => {
    mockDashboardApi();
  });

  it('renders dashboard cards without the retired placeholder sections', async () => {
    renderDashboard();

    expect(await screen.findByText('On air now')).toBeInTheDocument();
    expect(screen.getByText('System health')).toBeInTheDocument();
    expect(screen.queryByText('Recent activity')).not.toBeInTheDocument();
    expect(screen.queryByText('Release notes')).not.toBeInTheDocument();
  });

  it('renders dashboard cards and stats from live API responses', async () => {
    mockDashboardApi({
      channels: [
        {
          ffmpegProfile: 'HLS Direct',
          id: 1,
          language: 'en',
          name: 'Retro Cartoons',
          number: '5.1',
          streamingMode: 'HLS Direct'
        },
        {
          ffmpegProfile: 'MPEG-TS',
          id: 2,
          language: 'fr',
          name: 'News 24',
          number: '24',
          streamingMode: 'MPEG-TS'
        }
      ],
      channelStates: [
        {
          channelId: 1,
          channelNumber: '5.1',
          onAir: true,
          nowPlaying: {
            finishUtc: '2026-07-04T21:30:00Z',
            startUtc: '2026-07-04T21:00:00Z',
            title: 'Saturday Morning Cartoons'
          }
        },
        {
          channelId: 2,
          channelNumber: '24',
          onAir: false,
          nowPlaying: null
        }
      ],
      health: [
        {
          detail: 'SQLite is reachable',
          link: null,
          status: 'pass',
          title: 'Database'
        },
        {
          detail: 'FFmpeg path is missing',
          link: null,
          status: 'warn',
          title: 'FFmpeg'
        }
      ],
      mediaSources: [
        {
          connectionAddress: null,
          id: 30,
          kind: 'Local',
          libraries: [
            { id: 31, kind: 'Movies', name: 'Movies' },
            { id: 32, kind: 'Shows', name: 'Shows' }
          ],
          name: 'Local'
        }
      ],
      playouts: {
        page: [
          {
            buildStatus: {
              lastBuild: '2026-07-04T20:00:00Z',
              message: null,
              success: true
            },
            channelName: 'Retro Cartoons',
            channelNumber: '5.1',
            dailyRebuildTime: null,
            id: 20,
            scheduleKind: 'Classic',
            scheduleName: 'Default Schedule'
          }
        ],
        totalCount: 1
      }
    });

    renderDashboard();

    expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
    expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
    expect(screen.getByText('5.1')).toBeInTheDocument();
    expect(screen.queryByText('News 24')).not.toBeInTheDocument();
    expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
    expect(screen.getByText('1 on air')).toBeInTheDocument();
    expect(screen.getByText('1 warning')).toBeInTheDocument();
    expect(screen.getAllByText('2')).toHaveLength(2);
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/media-sources', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts', expect.any(Object));
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/health', expect.any(Object));
    expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/sessions', expect.any(Object));
    expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/schedules', expect.any(Object));
  });

  it('shows the dashboard loading state while requests are pending', async () => {
    vi.mocked(window.fetch).mockImplementation(() => new Promise<Response>(() => {}));

    renderDashboard();

    expect(await screen.findByText('Loading dashboard')).toBeInTheDocument();
  });

  it('shows an empty on-air state when no channel state is on air', async () => {
    mockDashboardApi({
      channels: [{ id: 1, name: 'Retro Cartoons', number: '5.1' }],
      channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: false, nowPlaying: null }]
    });

    renderDashboard();

    expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
  });

  it('renders guided remediation: in-app route navigates, external doc opens in a new tab (#164)', async () => {
    mockDashboardApi({
      health: [
        {
          detail: 'There are 3 items that do not exist on disk',
          brief: 'There are 3 items that do not exist on disk',
          link: '/app/trash',
          remediation: { kind: 'AppRoute', target: '/app/trash' },
          status: 'warn',
          title: 'File Not Found'
        },
        {
          detail: 'Downgrade detected; THIS IS NOT SUPPORTED',
          brief: 'Downgrade detected',
          link: 'https://ersatztv.org/docs/installation/#downgrading',
          remediation: { kind: 'ExternalDoc', target: 'https://ersatztv.org/docs/installation/#downgrading' },
          status: 'fail',
          title: 'ErsatzTV Downgrade'
        }
      ]
    });

    renderDashboard();

    // external-doc remediation is an anchor opening in a new tab
    const docLink = await screen.findByRole('link', { name: /View docs/ });
    expect(docLink).toHaveAttribute('href', 'https://ersatztv.org/docs/installation/#downgrading');
    expect(docLink).toHaveAttribute('target', '_blank');

    // app-route remediation is a button that performs client-side navigation
    const routeButton = screen.getByRole('button', { name: /Open Trash/ });
    fireEvent.click(routeButton);
    expect(window.location.pathname).toBe('/app/trash');
  });

  it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
    mockDashboardApi({
      health: [
        {
          detail: 'SQLite is reachable',
          link: null,
          status: 'pass',
          title: 'Database'
        },
        {
          detail: 'FFmpeg path is missing',
          link: null,
          status: 'fail',
          title: 'FFmpeg'
        },
        {
          detail: 'Scheduled maintenance window active',
          link: null,
          status: 'info',
          title: 'Maintenance'
        }
      ]
    });

    const { container } = renderDashboard();

    expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
    expect(screen.getByText('1 failing')).toBeInTheDocument();
    expect(container.querySelectorAll('.ctv-health-icon-error')).toHaveLength(1);
    expect(container.querySelectorAll('.ctv-health-icon-idle').length).toBeGreaterThanOrEqual(1);
  });

  it('refreshes health on demand without polling it', async () => {
    mockDashboardApi({
      health: [
        {
          detail: 'All checks passed',
          link: null,
          status: 'pass',
          title: 'System'
        }
      ]
    });

    renderDashboard();

    expect(await screen.findByText('All checks passed')).toBeInTheDocument();
    expect(fetchCount('/api/v1/health')).toBe(1);
    // Initial load uses the cached (non-forced) endpoint.
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/health', expect.any(Object));
    expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/health?refresh=true', expect.any(Object));

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

    expect(await screen.findByText('All checks passed')).toBeInTheDocument();
    expect(fetchCount('/api/v1/health')).toBe(2);
    // The explicit "Refresh health" action forces a fresh server-side run.
    expect(window.fetch).toHaveBeenCalledWith('/api/v1/health?refresh=true', expect.any(Object));
  });

  it('shows the API error detail when dashboard loading fails', async () => {
    vi.mocked(window.fetch).mockImplementation(() =>
      Promise.resolve(new Response(
        JSON.stringify({
          detail: 'API write key is invalid',
          status: 401,
          title: 'Unauthorized'
        }),
        {
          headers: { 'Content-Type': 'application/json' },
          status: 401
        }
      ))
    );

    renderDashboard();

    expect(await screen.findByText('API write key is invalid')).toBeInTheDocument();
  });
});

function DashboardUnderTest() {
  const healthState = useDashboardHealthQuery();
  return <DashboardScreen healthState={healthState} />;
}

function renderDashboard() {
  return render(<DashboardUnderTest />);
}

function mockDashboardApi({
  channels = [],
  channelStates = [],
  health = [],
  mediaSources = [],
  playouts = { page: [], totalCount: 0 }
}: {
  channels?: unknown[];
  channelStates?: unknown[];
  health?: unknown[];
  mediaSources?: unknown[];
  playouts?: unknown;
} = {}) {
  vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
    const path = input.toString();

    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/media-sources') {
      return Promise.resolve(jsonResponse(mediaSources));
    }

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

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

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

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

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