function jsonPost(fetchImpl, path, data = {}, options = {}) {
  return fetchImpl(path, {
    ...options,
    method: 'POST',
    headers: { ...options.headers, 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
}

export function createApiClient(fetchImpl = globalThis.fetch.bind(globalThis)) {
  return {
    auth: {
      me: () => fetchImpl('/api/auth/me'),
      login: (username, password) => jsonPost(
        fetchImpl, '/api/auth/login', { username, password }
      ),
      logout: () => jsonPost(fetchImpl, '/api/auth/logout'),
    },
    demos: {
      mine: () => fetchImpl('/api/demos/mine'),
      public: () => fetchImpl('/api/demos/public'),
      get: demoId => fetchImpl(`/api/demos/${demoId}`),
      create: (name = undefined) => jsonPost(
        fetchImpl,
        '/api/demos/create',
        name === undefined ? {} : { name },
      ),
      save: (demoId, project, sceneOverrides, updates = {}) => jsonPost(
        fetchImpl,
        `/api/demos/${demoId}/save`,
        { project, scene_overrides: sceneOverrides, ...updates },
      ),
      rename: (demoId, name, options = {}) => jsonPost(
        fetchImpl,
        `/api/demos/${demoId}/rename`,
        { name },
        options,
      ),
      publish: (demoId, updates) => jsonPost(
        fetchImpl, `/api/demos/${demoId}/publish`, updates
      ),
      exportHtml: (demoId, starCrawlTextures = []) => jsonPost(
        fetchImpl,
        `/api/demos/${demoId}/export-html`,
        { star_crawl_textures: starCrawlTextures },
      ),
      screenshot: (demoId, screenshot) => jsonPost(
        fetchImpl, `/api/demos/${demoId}/screenshot`, { screenshot }
      ),
      fork: sourceDemoId => jsonPost(
        fetchImpl, '/api/demos/fork', { source_demo_id: sourceDemoId }
      ),
      delete: demoId => jsonPost(fetchImpl, `/api/demos/${demoId}/delete`),
    },
    assets: {
      list: assetType => fetchImpl(
        `/api/assets/list?type=${encodeURIComponent(assetType)}`
      ),
      delete: path => jsonPost(fetchImpl, '/api/assets/delete', { path }),
      rename: (path, newName) => jsonPost(
        fetchImpl, '/api/assets/rename', { path, new_name: newName }
      ),
      setDefault: (path, isDefault) => jsonPost(
        fetchImpl, '/api/assets/set-default', { path, is_default: isDefault }
      ),
      saveGenerated: (filename, content, type, options = {}) => jsonPost(
        fetchImpl, '/api/assets/save-generated', { filename, content, type }, options
      ),
      upload: (file, type, onProgress = () => {}) => new Promise((resolve, reject) => {
        const formData = new FormData();
        formData.append('file', file);
        formData.append('type', type);
        const xhr = new XMLHttpRequest();
        xhr.upload.addEventListener('progress', event => {
          if (event.lengthComputable) onProgress(event.loaded / event.total);
        });
        xhr.addEventListener('load', () => {
          let response;
          try {
            response = JSON.parse(xhr.responseText);
          } catch {
            reject(new Error('Upload failed'));
            return;
          }
          if (xhr.status >= 200 && xhr.status < 300) resolve(response);
          else reject(new Error(response.error || 'Upload failed'));
        });
        xhr.addEventListener('error', () => reject(new Error('Network error')));
        xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
        xhr.open('POST', '/api/assets/upload');
        xhr.send(formData);
      }),
    },
    audio: {
      waveform: (audioPath, options = {}) => jsonPost(
        fetchImpl,
        '/api/audio/waveform',
        { audio_path: audioPath, ...options },
      ),
    },
  };
}
