/**
 * client.ts unit tests — Task 1 (TDD RED → GREEN)
 *
 * Tests for write client calls added in Plan 03-05:
 *   - createEvent: POSTs to /api/events/create with credentials:'include'
 *   - updateEvent: PATCHes /api/events/:uid/edit
 *   - fetchWritableCalendars: GETs /api/events/writable-calendars, returns WritableCalendar[]
 * Tests for Zustand store new keys:
 *   - eventFormOpen: boolean, defaults false
 *   - eventFormMode: 'create'|'edit', defaults 'create'
 *   - eventFormUid: string|null, defaults null
 *   - setEventForm: setter for all three
 *
 * Plan 06-05: SessionExpiredError detection tests (D-11)
 *   - fetchEvents / createEvent / updateEvent / deleteEvent with opaqueredirect → SessionExpiredError
 *   - fetchEvents / createEvent / updateEvent / deleteEvent with 401 → SessionExpiredError
 *   - 500 response does NOT produce SessionExpiredError
 *   - fetchMe's existing opaqueredirect/401 path now throws SessionExpiredError (unified)
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// ── Plan 06-05: SessionExpiredError detection tests ───────────────────────────

describe('SessionExpiredError — fetchEvents (06-05)', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('throws SessionExpiredError on opaqueredirect', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'opaqueredirect',
      status: 0,
    } as unknown as Response);

    const { fetchEvents, SessionExpiredError } = await import('./client.js');
    await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
      SessionExpiredError,
    );
  });

  it('throws SessionExpiredError on 401', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { fetchEvents, SessionExpiredError } = await import('./client.js');
    await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
      SessionExpiredError,
    );
  });

  it('throws a generic Error (not SessionExpiredError) on 500', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 500,
    } as unknown as Response);

    const { fetchEvents, SessionExpiredError } = await import('./client.js');
    await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toSatisfy(
      (e: unknown) => e instanceof Error && !(e instanceof SessionExpiredError),
    );
  });
});

describe('SessionExpiredError — createEvent (06-05)', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('throws SessionExpiredError on 401', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { createEvent, SessionExpiredError } = await import('./client.js');
    await expect(
      createEvent({
        title: 'Test',
        allDay: false,
        start: '2026-06-15T10:00:00',
        end: '2026-06-15T11:00:00',
      }),
    ).rejects.toBeInstanceOf(SessionExpiredError);
  });
});

describe('SessionExpiredError — updateEvent (06-05)', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('throws SessionExpiredError on 401', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { updateEvent, SessionExpiredError } = await import('./client.js');
    await expect(
      updateEvent('uid-123', {
        title: 'Test',
        allDay: false,
        start: '2026-06-15T10:00:00',
        end: '2026-06-15T11:00:00',
      }),
    ).rejects.toBeInstanceOf(SessionExpiredError);
  });
});

describe('SessionExpiredError — deleteEvent (06-05)', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('throws SessionExpiredError on 401', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { deleteEvent, SessionExpiredError } = await import('./client.js');
    await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError);
  });
});

describe('SessionExpiredError — fetchMe unified (06-05)', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('throws SessionExpiredError (instanceof) on opaqueredirect — unified with other wrappers', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'opaqueredirect',
      status: 0,
    } as unknown as Response);

    const { fetchMe, SessionExpiredError } = await import('./client.js');
    await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
  });

  it('throws SessionExpiredError on 401 — unified', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { fetchMe, SessionExpiredError } = await import('./client.js');
    await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
  });
});

// ── createEvent tests ─────────────────────────────────────────────────────────

describe('createEvent', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('POSTs to /api/events/create with credentials:include', async () => {
    const mockFetch = vi.mocked(fetch);
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'test-uid-123' }),
    } as unknown as Response);

    const { createEvent } = await import('./client.js');
    const payload = {
      title: 'Team Meeting',
      allDay: false,
      start: '2026-06-15T10:00:00',
      end: '2026-06-15T11:00:00',
      recurrence: 'none' as const,
    };
    await createEvent(payload);

    expect(mockFetch).toHaveBeenCalledWith(
      '/api/events/create',
      expect.objectContaining({
        method: 'POST',
        credentials: 'include',
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns 'any' (Vitest asymmetric matcher); safe in assertion context
        headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
      }),
    );
  });

  it('returns { uid } from 202 response', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'returned-uid-456' }),
    } as unknown as Response);

    const { createEvent } = await import('./client.js');
    const result = await createEvent({
      title: 'Test',
      allDay: true,
      start: '2026-06-20',
      end: '2026-06-20',
      recurrence: 'none' as const,
    });

    expect(result).toEqual({ uid: 'returned-uid-456' });
  });

  it('throws on non-ok response', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 400,
      json: () => ({ error: 'Bad Request' }),
    } as unknown as Response);

    const { createEvent } = await import('./client.js');
    await expect(
      createEvent({
        title: '',
        allDay: false,
        start: '2026-06-15T10:00:00',
        end: '2026-06-15T11:00:00',
        recurrence: 'none' as const,
      }),
    ).rejects.toThrow();
  });
});

// ── updateEvent tests ─────────────────────────────────────────────────────────

describe('updateEvent', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('PATCHes /api/events/:uid/edit', async () => {
    const mockFetch = vi.mocked(fetch);
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'edit-uid-789' }),
    } as unknown as Response);

    const { updateEvent } = await import('./client.js');
    await updateEvent('edit-uid-789', {
      title: 'Updated Meeting',
      allDay: false,
      start: '2026-06-15T10:00:00',
      end: '2026-06-15T11:00:00',
      recurrence: 'weekly' as const,
    });

    expect(mockFetch).toHaveBeenCalledWith(
      '/api/events/edit-uid-789/edit',
      expect.objectContaining({
        method: 'PATCH',
        credentials: 'include',
      }),
    );
  });

  it('returns { uid } on success', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'patched-uid' }),
    } as unknown as Response);

    const { updateEvent } = await import('./client.js');
    const result = await updateEvent('some-uid', {
      title: 'Patched',
      allDay: false,
      start: '2026-06-15T10:00:00',
      end: '2026-06-15T11:00:00',
      recurrence: 'none' as const,
    });

    expect(result).toEqual({ uid: 'patched-uid' });
  });
});

// ── fetchWritableCalendars tests ──────────────────────────────────────────────

describe('fetchWritableCalendars', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('GETs /api/events/writable-calendars with credentials:include', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({
        calendars: [
          {
            url: 'https://caldav.fastmail.com/cal1',
            displayName: 'My Calendar',
            color: '#4A90D9',
            isShared: false,
          },
        ],
      }),
    } as unknown as Response);

    const { fetchWritableCalendars } = await import('./client.js');
    await fetchWritableCalendars();

    expect(vi.mocked(fetch)).toHaveBeenCalledWith(
      '/api/events/writable-calendars',
      expect.objectContaining({ credentials: 'include' }),
    );
  });

  it('returns WritableCalendar[] from the calendars envelope', async () => {
    const mockCalendars = [
      {
        url: 'https://caldav.fastmail.com/cal1',
        displayName: 'My Calendar',
        color: '#4A90D9',
        isShared: false,
      },
      {
        url: 'https://caldav.fastmail.com/cal2',
        displayName: 'Family',
        color: '#F25C7A',
        isShared: true,
      },
    ];
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ calendars: mockCalendars }),
    } as unknown as Response);

    const { fetchWritableCalendars } = await import('./client.js');
    const result = await fetchWritableCalendars();

    expect(result).toEqual(mockCalendars);
    expect(result).toHaveLength(2);
  });

  it('throws on non-ok response', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 401,
    } as unknown as Response);

    const { fetchWritableCalendars } = await import('./client.js');
    await expect(fetchWritableCalendars()).rejects.toThrow();
  });
});

// ── deleteEvent tests ─────────────────────────────────────────────────────────

describe('deleteEvent', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('DELETEs /api/events/:uid with credentials:include', async () => {
    const mockFetch = vi.mocked(fetch);
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => ({}),
    } as unknown as Response);

    const { deleteEvent } = await import('./client.js');
    await deleteEvent('uid-to-delete');

    expect(mockFetch).toHaveBeenCalledWith(
      '/api/events/uid-to-delete',
      expect.objectContaining({
        method: 'DELETE',
        credentials: 'include',
      }),
    );
  });

  it('resolves void on success (204/202)', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({}),
    } as unknown as Response);

    const { deleteEvent } = await import('./client.js');
    const result = await deleteEvent('uid-abc');
    expect(result).toBeUndefined();
  });

  it('throws on non-ok response', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 404,
    } as unknown as Response);

    const { deleteEvent } = await import('./client.js');
    await expect(deleteEvent('missing-uid')).rejects.toThrow();
  });
});

// ── fetchSyncStatus tests ─────────────────────────────────────────────────────

describe('fetchSyncStatus', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('GETs /api/events/sync-status?uid= with credentials:include', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'my-uid', status: 'pending' }),
    } as unknown as Response);

    const { fetchSyncStatus } = await import('./client.js');
    await fetchSyncStatus('my-uid');

    expect(vi.mocked(fetch)).toHaveBeenCalledWith(
      '/api/events/sync-status?uid=my-uid',
      expect.objectContaining({ credentials: 'include' }),
    );
  });

  it('returns SyncStatus object with uid and status', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'test-uid', status: 'done' }),
    } as unknown as Response);

    const { fetchSyncStatus } = await import('./client.js');
    const result = await fetchSyncStatus('test-uid');
    expect(result).toEqual({ uid: 'test-uid', status: 'done' });
  });

  it('returns SyncStatus with error field for failed status', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }),
    } as unknown as Response);

    const { fetchSyncStatus } = await import('./client.js');
    const result = await fetchSyncStatus('fail-uid');
    expect(result).toEqual({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' });
  });

  it('throws on non-ok response', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 500,
    } as unknown as Response);

    const { fetchSyncStatus } = await import('./client.js');
    await expect(fetchSyncStatus('any-uid')).rejects.toThrow();
  });
});

// ── fetchMe tests ─────────────────────────────────────────────────────────────

describe('fetchMe', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('requests /api/me with credentials:include AND redirect:manual', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      type: 'basic',
      status: 200,
      json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
    } as unknown as Response);

    const { fetchMe } = await import('./client.js');
    await fetchMe();

    expect(vi.mocked(fetch)).toHaveBeenCalledWith(
      '/api/me',
      expect.objectContaining({ credentials: 'include', redirect: 'manual' }),
    );
  });

  it('returns the user envelope on 200', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      type: 'basic',
      status: 200,
      json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
    } as unknown as Response);

    const { fetchMe } = await import('./client.js');
    const result = await fetchMe();
    expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } });
  });

  it('throws on an opaqueredirect (OIDC guard 302 to Authelia) — the auth-required signal', async () => {
    // redirect:'manual' surfaces a 302 as an opaqueredirect with status 0 and
    // ok:false instead of following it (which would hang the fetch).
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'opaqueredirect',
      status: 0,
      json: () => {
        throw new Error('body not accessible on opaqueredirect');
      },
    } as unknown as Response);

    const { fetchMe } = await import('./client.js');
    await expect(fetchMe()).rejects.toThrow(/authentication required/i);
  });

  it('throws on a 401', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      type: 'basic',
      status: 401,
    } as unknown as Response);

    const { fetchMe } = await import('./client.js');
    await expect(fetchMe()).rejects.toThrow(/authentication required/i);
  });
});

// ── Zustand store new keys tests ──────────────────────────────────────────────

describe('calendarStore — eventForm keys', () => {
  it('eventFormOpen defaults to false', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.eventFormOpen).toBe(false);
  });

  it('eventFormMode defaults to create', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.eventFormMode).toBe('create');
  });

  it('eventFormUid defaults to null', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.eventFormUid).toBeNull();
  });

  it('setEventForm(true, edit, some-uid) updates all three keys', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid');
    const state = useCalendarStore.getState();
    expect(state.eventFormOpen).toBe(true);
    expect(state.eventFormMode).toBe('edit');
    expect(state.eventFormUid).toBe('some-uid');
  });

  it('setEventForm(false) closes the form and preserves other defaults', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    // Open first
    useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc');
    // Close
    useCalendarStore.getState().setEventForm(false);
    const state = useCalendarStore.getState();
    expect(state.eventFormOpen).toBe(false);
  });

  it('setEventForm(true) with no mode/uid defaults to create/null', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setEventForm(true);
    const state = useCalendarStore.getState();
    expect(state.eventFormOpen).toBe(true);
    expect(state.eventFormMode).toBe('create');
    expect(state.eventFormUid).toBeNull();
  });
});

describe('calendarStore — delete/sync keys', () => {
  it('deleteDialogOpen defaults to false', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.deleteDialogOpen).toBe(false);
  });

  it('deleteDialogUid defaults to null', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.deleteDialogUid).toBeNull();
  });

  it('lastSyncedUid defaults to null', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    const state = useCalendarStore.getState();
    expect(state.lastSyncedUid).toBeNull();
  });

  it('setDeleteDialog(true, uid) opens dialog with uid', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setDeleteDialog(true, 'delete-uid-999');
    const state = useCalendarStore.getState();
    expect(state.deleteDialogOpen).toBe(true);
    expect(state.deleteDialogUid).toBe('delete-uid-999');
  });

  it('setDeleteDialog(false) closes dialog', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setDeleteDialog(true, 'some-uid');
    useCalendarStore.getState().setDeleteDialog(false);
    const state = useCalendarStore.getState();
    expect(state.deleteDialogOpen).toBe(false);
  });

  it('setLastSyncedUid sets the UID', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setLastSyncedUid('synced-uid-abc');
    const state = useCalendarStore.getState();
    expect(state.lastSyncedUid).toBe('synced-uid-abc');
  });

  it('setLastSyncedUid(null) clears the UID', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setLastSyncedUid('some-uid');
    useCalendarStore.getState().setLastSyncedUid(null);
    const state = useCalendarStore.getState();
    expect(state.lastSyncedUid).toBeNull();
  });
});
