import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
  changePassword,
  clearLegacyStoredApiKey,
  getAuthConfig,
  getAuthSession,
  getMachineKey,
  login,
  logout,
  notifyUnauthorized,
  setup,
  subscribeUnauthorized
} from './auth';

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

describe('legacy API-key cleanup', () => {
  beforeEach(() => {
    window.localStorage.clear();
  });

  it('removes a leftover ctv-api-key entry', () => {
    window.localStorage.setItem('ctv-api-key', 'legacy-secret');

    clearLegacyStoredApiKey();

    expect(window.localStorage.getItem('ctv-api-key')).toBeNull();
  });

  it('is a no-op when localStorage is unavailable', () => {
    const localStorageGetter = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
      throw new Error('localStorage unavailable');
    });

    expect(() => clearLegacyStoredApiKey()).not.toThrow();

    localStorageGetter.mockRestore();
  });
});

describe('auth endpoints', () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it('GET /api/v1/auth/config', async () => {
    const fetchMock = vi
      .spyOn(window, 'fetch')
      .mockResolvedValue(jsonResponse({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false }));

    const config = await getAuthConfig();

    expect(config).toEqual({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false });
    expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/config', expect.objectContaining({ method: 'GET' }));
  });

  it('GET /api/v1/auth/session', async () => {
    const fetchMock = vi
      .spyOn(window, 'fetch')
      .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));

    const session = await getAuthSession();

    expect(session).toEqual({ authenticated: true, username: 'admin', method: 'local' });
    expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/session', expect.objectContaining({ method: 'GET' }));
  });

  it('GET /api/v1/auth/session for an anonymous caller (username/method omitted)', async () => {
    // The server serializes anonymous sessions as `{ "authenticated": false }` — username/method are
    // dropped by Newtonsoft's global NullValueHandling.Ignore, so AuthSession must treat them as
    // optional/undefined rather than present-but-null.
    vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ authenticated: false }));

    const session = await getAuthSession();

    expect(session).toEqual({ authenticated: false });
    expect(session.username).toBeUndefined();
    expect(session.method).toBeUndefined();
  });

  it('POST /api/v1/auth/login with credentials in the body', async () => {
    const fetchMock = vi
      .spyOn(window, 'fetch')
      .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));

    await login('admin', 'hunter2');

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/v1/auth/login',
      expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' })
    );
  });

  it('login does not trip the global 401 banner on a wrong-password 401', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ status: 401, title: 'Unauthorized' }, 401));
    const onUnauthorized = vi.fn();
    const unsubscribe = subscribeUnauthorized(onUnauthorized);

    await expect(login('admin', 'wrong')).rejects.toMatchObject({ status: 401 });

    expect(onUnauthorized).not.toHaveBeenCalled();
    unsubscribe();
  });

  it('POST /api/v1/auth/setup with credentials in the body', async () => {
    const fetchMock = vi
      .spyOn(window, 'fetch')
      .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));

    await setup('admin', 'hunter2');

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/v1/auth/setup',
      expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' })
    );
  });

  it('POST /api/v1/auth/logout', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));

    await logout();

    expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', expect.objectContaining({ method: 'POST' }));
  });

  it('POST /api/v1/auth/password with both passwords, suppressing the 401 banner', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));

    await changePassword('old', 'new');

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/v1/auth/password',
      expect.objectContaining({ body: JSON.stringify({ currentPassword: 'old', newPassword: 'new' }), method: 'POST' })
    );
  });

  it('GET /api/v1/auth/machine-key', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ apiKey: 'abc123' }));

    const result = await getMachineKey();

    expect(result).toEqual({ apiKey: 'abc123' });
    expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/machine-key', expect.objectContaining({ method: 'GET' }));
  });
});

describe('unauthorized signal', () => {
  it('notifies subscribers and stops after unsubscribe', () => {
    const listener = vi.fn();
    const unsubscribe = subscribeUnauthorized(listener);

    notifyUnauthorized();
    expect(listener).toHaveBeenCalledTimes(1);

    unsubscribe();
    notifyUnauthorized();
    expect(listener).toHaveBeenCalledTimes(1);
  });
});
