// SPDX-License-Identifier: GPL-3.0-or-later

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
  health,
  getStatus,
  getTorStatus,
  getServerStatus,
  getAccount,
  register,
  startBackup,
  getBackupStatus,
  cancelBackup,
  startRestore,
  getRestoreStatus,
  cancelRestore,
  listSnapshots,
  getSnapshot,
  deleteSnapshot,
  listJobs,
  getJob,
  cancelJob,
  getConfig,
  updateConfig,
  getMode,
  updateMode,
  getPaths,
  addPath,
  removePath,
  getRetention,
} from './api';

describe('API Client', () => {
  const mockFetch = vi.fn();
  const originalFetch = global.fetch;

  beforeEach(() => {
    global.fetch = mockFetch;
  });

  afterEach(() => {
    global.fetch = originalFetch;
    mockFetch.mockReset();
  });

  function mockOk(data: unknown) {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: async () => JSON.stringify(data),
      json: async () => data,
    });
  }

  function mockError(status: number, body: unknown) {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status,
      statusText: `Error ${status}`,
      json: async () => body,
    });
  }

  function mockEmpty() {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: async () => '',
      json: async () => ({}),
    });
  }

  function mockNetworkError() {
    mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
  }

  // ---- Health ----

  describe('health', () => {
    it('returns status ok', async () => {
      mockOk({ status: 'ok' });
      const result = await health();
      expect(result.status).toBe('ok');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/health',
        expect.objectContaining({ headers: expect.any(Object) })
      );
    });

    it('throws on server error', async () => {
      mockError(500, { error: 'internal' });
      await expect(health()).rejects.toThrow('internal');
    });

    it('throws on network error', async () => {
      mockNetworkError();
      await expect(health()).rejects.toThrow('Failed to fetch');
    });
  });

  // ---- Status endpoints ----

  describe('getStatus', () => {
    it('returns full status', async () => {
      mockOk({
        status: 'running',
        version: '0.2.0',
        uptime: '2h30m',
        timestamp: '2025-01-15T10:00:00Z',
      });
      const result = await getStatus();
      expect(result.status).toBe('running');
      expect(result.version).toBe('0.2.0');
    });
  });

  describe('getTorStatus', () => {
    it('returns tor status', async () => {
      mockOk({ connected: true, circuit_count: 3, bootstrap_pct: 100 });
      const result = await getTorStatus();
      expect(result.connected).toBe(true);
      expect(result.circuit_count).toBe(3);
    });

    it('uses correct endpoint', async () => {
      mockOk({ connected: false });
      await getTorStatus();
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/status/tor',
        expect.any(Object)
      );
    });
  });

  describe('getServerStatus', () => {
    it('returns server status', async () => {
      mockOk({
        connected: true,
        account_status: 'active',
        credits_remaining: 5.5,
      });
      const result = await getServerStatus();
      expect(result.connected).toBe(true);
      expect(result.credits_remaining).toBe(5.5);
    });
  });

  // ---- Account ----

  describe('getAccount', () => {
    it('returns account info', async () => {
      mockOk({
        cert_hash: 'abc123',
        credits_remaining: 10.0,
        storage_used_bytes: 1024000,
        status: 'active',
      });
      const result = await getAccount();
      expect(result.cert_hash).toBe('abc123');
      expect(result.status).toBe('active');
    });

    it('throws on 404', async () => {
      mockError(404, { error: 'not found' });
      await expect(getAccount()).rejects.toThrow('not found');
    });
  });

  describe('register', () => {
    it('sends server address', async () => {
      mockOk({
        cert_hash: 'new123',
        status: 'active',
      });
      await register('example.onion');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/account/register',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ server_address: 'example.onion' }),
        })
      );
    });

    it('sends undefined server address', async () => {
      mockOk({ cert_hash: 'new123', status: 'active' });
      await register();
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.server_address).toBeUndefined();
    });
  });

  // ---- Backup ----

  describe('startBackup', () => {
    it('sends backup request', async () => {
      mockOk({ id: 'job-1', type: 'backup', status: 'running', progress: 0 });
      const result = await startBackup({ paths: ['/home/docs'] });
      expect(result.id).toBe('job-1');
      expect(result.status).toBe('running');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/backup/start',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ paths: ['/home/docs'] }),
        })
      );
    });

    it('includes exclude patterns and tags', async () => {
      mockOk({ id: 'job-2', status: 'running' });
      await startBackup({
        paths: ['/home'],
        exclude: ['*.tmp'],
        tags: { type: 'manual' },
      });
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.exclude).toEqual(['*.tmp']);
      expect(body.tags).toEqual({ type: 'manual' });
    });
  });

  describe('getBackupStatus', () => {
    it('returns backup job', async () => {
      mockOk({ id: 'job-1', status: 'completed', progress: 100 });
      const result = await getBackupStatus();
      expect(result.progress).toBe(100);
    });
  });

  describe('cancelBackup', () => {
    it('sends POST to cancel endpoint', async () => {
      mockEmpty();
      await cancelBackup();
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/backup/cancel',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  // ---- Restore ----

  describe('startRestore', () => {
    it('sends restore request', async () => {
      mockOk({ id: 'job-r1', status: 'running' });
      const result = await startRestore({
        snapshot_id: 'snap-abc',
        target: '/tmp/restore',
      });
      expect(result.id).toBe('job-r1');
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.snapshot_id).toBe('snap-abc');
      expect(body.target).toBe('/tmp/restore');
    });

    it('includes optional paths filter', async () => {
      mockOk({ id: 'job-r2', status: 'running' });
      await startRestore({
        snapshot_id: 'snap-abc',
        target: '/tmp/restore',
        paths: ['/home/docs'],
      });
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.paths).toEqual(['/home/docs']);
    });
  });

  describe('getRestoreStatus', () => {
    it('returns restore job', async () => {
      mockOk({ id: 'job-r1', status: 'completed' });
      const result = await getRestoreStatus();
      expect(result.status).toBe('completed');
    });
  });

  describe('cancelRestore', () => {
    it('sends POST to cancel', async () => {
      mockEmpty();
      await cancelRestore();
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/restore/cancel',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  // ---- Snapshots ----

  describe('listSnapshots', () => {
    it('returns snapshot array', async () => {
      mockOk([
        { id: 'snap-1', time: '2025-01-15', hostname: 'host', paths: ['/docs'] },
        { id: 'snap-2', time: '2025-01-14', hostname: 'host', paths: ['/docs'] },
      ]);
      const result = await listSnapshots();
      expect(result).toHaveLength(2);
      expect(result[0].id).toBe('snap-1');
    });

    it('returns empty array', async () => {
      mockOk([]);
      const result = await listSnapshots();
      expect(result).toHaveLength(0);
    });
  });

  describe('getSnapshot', () => {
    it('returns single snapshot by id', async () => {
      mockOk({ id: 'snap-1', paths: ['/docs'], size: 1024000 });
      const result = await getSnapshot('snap-1');
      expect(result.id).toBe('snap-1');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/snapshots/snap-1',
        expect.any(Object)
      );
    });
  });

  describe('deleteSnapshot', () => {
    it('sends DELETE request', async () => {
      mockEmpty();
      await deleteSnapshot('snap-1');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/snapshots/snap-1',
        expect.objectContaining({ method: 'DELETE' })
      );
    });
  });

  // ---- Jobs ----

  describe('listJobs', () => {
    it('returns all jobs without filter', async () => {
      mockOk([{ id: 'j1', type: 'backup', status: 'completed' }]);
      const result = await listJobs();
      expect(result).toHaveLength(1);
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/jobs',
        expect.any(Object)
      );
    });

    it('filters by status', async () => {
      mockOk([]);
      await listJobs('running');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/jobs?status=running',
        expect.any(Object)
      );
    });
  });

  describe('getJob', () => {
    it('returns job by id', async () => {
      mockOk({ id: 'j1', status: 'running', progress: 50 });
      const result = await getJob('j1');
      expect(result.progress).toBe(50);
    });
  });

  describe('cancelJob', () => {
    it('sends POST to cancel', async () => {
      mockEmpty();
      await cancelJob('j1');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/jobs/j1/cancel',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  // ---- Config ----

  describe('getConfig', () => {
    it('returns configuration', async () => {
      mockOk({
        backup_paths: ['/docs'],
        mode: 'simple',
        tor_enabled: true,
        auto_prune: true,
      });
      const result = await getConfig();
      expect(result.mode).toBe('simple');
      expect(result.tor_enabled).toBe(true);
    });
  });

  describe('updateConfig', () => {
    it('sends PUT with partial config', async () => {
      mockOk({
        backup_paths: ['/docs', '/photos'],
        mode: 'simple',
      });
      await updateConfig({ backup_paths: ['/docs', '/photos'] });
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/config',
        expect.objectContaining({
          method: 'PUT',
          body: JSON.stringify({ backup_paths: ['/docs', '/photos'] }),
        })
      );
    });
  });

  // ---- Mode ----

  describe('getMode', () => {
    it('returns current mode', async () => {
      mockOk({ mode: 'simple', description: 'Simple mode', features: ['auto-prune'] });
      const result = await getMode();
      expect(result.mode).toBe('simple');
      expect(result.features).toEqual(['auto-prune']);
    });
  });

  describe('updateMode', () => {
    it('sends mode and acknowledge', async () => {
      mockOk({ mode: 'advanced', description: 'Advanced' });
      await updateMode('advanced', true);
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.mode).toBe('advanced');
      expect(body.acknowledge).toBe(true);
    });

    it('sends without acknowledge', async () => {
      mockOk({ mode: 'simple' });
      await updateMode('simple');
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.mode).toBe('simple');
      expect(body.acknowledge).toBeUndefined();
    });
  });

  // ---- Paths ----

  describe('getPaths', () => {
    it('returns paths response', async () => {
      mockOk({
        paths: [{ path: '/docs', exists: true, is_dir: true }],
        total_paths: 1,
      });
      const result = await getPaths();
      expect(result.total_paths).toBe(1);
      expect(result.paths[0].path).toBe('/docs');
    });
  });

  describe('addPath', () => {
    it('sends POST with path', async () => {
      mockOk({ paths: [], total_paths: 1 });
      await addPath('/new/path');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/paths',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ path: '/new/path' }),
        })
      );
    });
  });

  describe('removePath', () => {
    it('sends DELETE with path', async () => {
      mockOk({ paths: [], total_paths: 0 });
      await removePath('/old/path');
      expect(mockFetch).toHaveBeenCalledWith(
        '/api/v1/paths',
        expect.objectContaining({
          method: 'DELETE',
          body: JSON.stringify({ path: '/old/path' }),
        })
      );
    });
  });

  // ---- Retention ----

  describe('getRetention', () => {
    it('returns retention policy', async () => {
      mockOk({
        keep_within_daily: '7d',
        keep_within_weekly: '4w',
        keep_within_monthly: '12m',
        keep_within_yearly: '10y',
      });
      const result = await getRetention();
      expect(result.keep_within_daily).toBe('7d');
      expect(result.keep_within_yearly).toBe('10y');
    });
  });

  // ---- Error handling ----

  describe('error handling', () => {
    it('parses error response with code and details', async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 400,
        statusText: 'Bad Request',
        json: async () => ({
          error: 'invalid path',
          code: 'INVALID_PATH',
          details: 'Path must be absolute',
        }),
      });

      try {
        await addPath('relative/path');
        expect.fail('should have thrown');
      } catch (err: unknown) {
        const error = err as Error & { code?: string; details?: string };
        expect(error.message).toBe('invalid path');
        expect(error.code).toBe('INVALID_PATH');
        expect(error.details).toBe('Path must be absolute');
      }
    });

    it('handles non-JSON error response', async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 502,
        statusText: 'Bad Gateway',
        json: async () => { throw new Error('not json'); },
      });

      await expect(health()).rejects.toThrow('HTTP 502: Bad Gateway');
    });

    it('handles empty successful response', async () => {
      mockEmpty();
      const result = await cancelBackup();
      expect(result).toBeUndefined();
    });
  });

  // ---- Headers ----

  describe('request headers', () => {
    it('always sends Content-Type json', async () => {
      mockOk({ status: 'ok' });
      await health();
      const headers = mockFetch.mock.calls[0][1].headers;
      expect(headers['Content-Type']).toBe('application/json');
    });
  });
});
