util_RestApi.js

import { Helper } from "../util/Helper.js";

/**
 * RestApi
 * Utility class for REST Api development wraps native fetch internally.
 *
 * @example <caption>Using callbacks</caption>
 * const myRestApi = new RestApi( 'https://api.example.com' );
 * myRestApi.get( '/books', function( err, result ) { } );
 *
 * @example <caption>Using await</caption>
 * const myRestApi = new RestApi( 'https://api.example.com' );
 * try
 * {
 *     const result = await myRestApi.get( '/books' );
 * }
 * catch( e )
 * {
 *     // Handle error
 * }
 *
 * @example <caption>With timeout and retry configuration</caption>
 * const myRestApi = new RestApi( 'https://api.example.com', {}, {
 *     timeout: 10000,      // 10 second timeout
 *     retryAttempts: 3,    // Retry up to 3 times
 *     retryDelay: 1000     // Wait 1 second between retries
 * });
 *
 * @example <caption>Per-request timeout and retry override</caption>
 * const result = await myRestApi.get('/books', null, {
 *     timeout: 5000,       // Override to 5 second timeout
 *     retryAttempts: 1     // Override to 1 retry attempt
 * });
 *
 * @example <caption>Headers management</caption>
 * myRestApi.addHeader('Authorization', 'Bearer token123');
 * myRestApi.addHeader('Content-Type', 'application/json');
 * myRestApi.removeHeader('Content-Type');
 * const headers = myRestApi.getHeaders(); // Get copy of current headers
 *
 * @example <caption>Query parameters</caption>
 * // Simple query parameters
 * const books = await myRestApi.get('/books', null, {
 *     queryParams: { page: 1, limit: 10, search: 'javascript' }
 * });
 * // Results in: GET /books?page=1&limit=10&search=javascript
 *
 * // Array parameters
 * const filtered = await myRestApi.get('/books', null, {
 *     queryParams: { tags: ['fiction', 'drama'], author: 'Shakespeare' }
 * });
 * // Results in: GET /books?tags=fiction&tags=drama&author=Shakespeare
 *
 * @example <caption>Request cancellation</caption>
 * // Make a request and get the request ID
 * const { requestId, result } = await myRestApi.get('/large-data');
 * 
 * // Make a request with callback and get request ID immediately
 * const { requestId } = await myRestApi.get('/books', (err, result) => {
 *     if (err) console.error('Request failed:', err);
 *     else console.log('Books:', result);
 * });
 *
 * // Cancel the specific request
 * const cancelled = myRestApi.cancelRequest(requestId);
 * console.log('Request cancelled:', cancelled);
 *
 * // Check if request is still pending
 * if (myRestApi.isRequestPending(requestId)) {
 *     console.log('Request is still running');
 * }
 *
 * // Get all pending requests
 * const pending = myRestApi.getPendingRequests();
 * console.log('Pending requests:', pending);
 *
 * // Cancel all requests
 * myRestApi.abortAll();
 *
 * @example <caption>Progress tracking</caption>
 * // Upload progress tracking
 * const formData = new FormData();
 * formData.append('file', fileInput.files[0]);
 * 
 * const { requestId } = await myRestApi.post('/upload', formData, null, {
 *     onUploadProgress: (progress) => {
 *         console.log(`Upload: ${progress.percent}% (${progress.loaded}/${progress.total} bytes)`);
 *         // Update progress bar: progressBar.value = progress.percent;
 *     }
 * });
 *
 * // Download progress tracking
 * const { requestId, result } = await myRestApi.get('/large-file', null, {
 *     onDownloadProgress: (progress) => {
 *         console.log(`Download: ${progress.percent}% (${progress.loaded}/${progress.total} bytes)`);
 *         // Update progress bar: downloadBar.value = progress.percent;
 *     }
 * });
 *
 * // Both upload and download progress
 * const { requestId } = await myRestApi.put('/documents/123', documentData, null, {
 *     onUploadProgress: (progress) => {
 *         console.log('Uploading:', progress.percent + '%');
 *     },
 *     onDownloadProgress: (progress) => {
 *         console.log('Downloading response:', progress.percent + '%');
 *     }
 * });
 *
 */
class RestApi
{
    /**
     * Constructor
     * @param {string} url - Base url
     * @param {Headers=} headers - Header data.
     * @param {object=} options - Configuration options
     * @param {number=} options.timeout - Request timeout in milliseconds (default: 30000)
     * @param {number=} options.retryAttempts - Number of retry attempts (default: 0)
     * @param {number=} options.retryDelay - Delay between retries in milliseconds (default: 1000)
     */
    constructor( url = '', headers = {}, options = {} )
    {
        this.url = Helper.trim( url, '/' );
        if ( this.url.length <= 1 )
        {
            throw new Error( 'No endpoint set.' );
        }

        this.headers = new Headers( headers );
        this._controllers = new Set();
        this._requestMap = new Map(); // Map request IDs to controllers
        this._requestIdCounter = 0;
        
        // Default configuration
        this.timeout = options.timeout || 30000;
        this.retryAttempts = options.retryAttempts || 0;
        this.retryDelay = options.retryDelay || 1000;
    }

    /**
     * GET call
     * @param {string} endpoint - API endpoint
     * @param {function=} [cb=null] - Callback function
     * @param {object=} [options={}] - Request options
     * @param {number=} options.timeout - Request timeout in milliseconds
     * @param {number=} options.retryAttempts - Number of retry attempts
     * @param {number=} options.retryDelay - Delay between retries in milliseconds
     * @param {object=} options.queryParams - Query parameters object
     * @param {function=} options.onDownloadProgress - Download progress callback
     * @returns {Promise<{requestId: string, result: (any|undefined)}>} Promise with request ID and result (if no callback)
     */
    async get( endpoint, cb = null, options = {} )
    {
        const { queryParams, ...fetchOptions } = options;
        const url = this._buildUrl(endpoint, queryParams);
        const fetchOpts = this._createFetchOptions( "GET" );
        const req = new Request( url, fetchOpts );
        const requestId = fetchOpts.signal.requestId;

        if (cb) {
            await this._fetch( req, cb, fetchOptions );
            return { requestId };
        } else {
            const result = await this._fetch( req, cb, fetchOptions );
            return { requestId, result };
        }
    }

    /**
     * POST call
     * @param {string} endpoint - API endpoint
     * @param {object=} [data={}] - Post data
     * @param {function=} [cb=null] - Callback function
     * @param {object=} [options={}] - Request options
     * @param {number=} options.timeout - Request timeout in milliseconds
     * @param {number=} options.retryAttempts - Number of retry attempts
     * @param {number=} options.retryDelay - Delay between retries in milliseconds
     * @param {object=} options.queryParams - Query parameters object
     * @param {function=} options.onUploadProgress - Upload progress callback
     * @param {function=} options.onDownloadProgress - Download progress callback
     * @returns {Promise<{requestId: string, result: (any|undefined)}>} Promise with request ID and result (if no callback)
     */
    async post( endpoint, data = {}, cb = null, options = {} )
    {
        const { queryParams, ...fetchOptions } = options;
        const url = this._buildUrl(endpoint, queryParams);
        const fetchOpts = this._createFetchOptions( "POST", data );
        const req = new Request( url, fetchOpts );
        const requestId = fetchOpts.signal.requestId;

        if (cb) {
            await this._fetch( req, cb, fetchOptions );
            return { requestId };
        } else {
            const result = await this._fetch( req, cb, fetchOptions );
            return { requestId, result };
        }
    }

    /**
     * DELETE call
     * @param {string} endpoint - API endpoint
     * @param {function=} [cb=null] - Callback function
     * @param {object=} [options={}] - Request options
     * @param {number=} options.timeout - Request timeout in milliseconds
     * @param {number=} options.retryAttempts - Number of retry attempts
     * @param {number=} options.retryDelay - Delay between retries in milliseconds
     * @param {object=} options.queryParams - Query parameters object
     * @param {function=} options.onDownloadProgress - Download progress callback
     * @returns {Promise<{requestId: string, result: (any|undefined)}>} Promise with request ID and result (if no callback)
     */
    async delete( endpoint, cb = null, options = {} )
    {
        const { queryParams, ...fetchOptions } = options;
        const url = this._buildUrl(endpoint, queryParams);
        const fetchOpts = this._createFetchOptions( "DELETE" );
        const req = new Request( url, fetchOpts );
        const requestId = fetchOpts.signal.requestId;

        if (cb) {
            await this._fetch( req, cb, fetchOptions );
            return { requestId };
        } else {
            const result = await this._fetch( req, cb, fetchOptions );
            return { requestId, result };
        }
    }

    /**
     * PUT call
     * @param {string} endpoint - API endpoint
     * @param {object=} [data={}] - PUT data
     * @param {function=} [cb=null] - Callback function
     * @param {object=} [options={}] - Request options
     * @param {number=} options.timeout - Request timeout in milliseconds
     * @param {number=} options.retryAttempts - Number of retry attempts
     * @param {number=} options.retryDelay - Delay between retries in milliseconds
     * @param {object=} options.queryParams - Query parameters object
     * @param {function=} options.onUploadProgress - Upload progress callback
     * @param {function=} options.onDownloadProgress - Download progress callback
     * @returns {Promise<{requestId: string, result: (any|undefined)}>} Promise with request ID and result (if no callback)
     */
    async put( endpoint, data = {}, cb = null, options = {} )
    {
        const { queryParams, ...fetchOptions } = options;
        const url = this._buildUrl(endpoint, queryParams);
        const fetchOpts = this._createFetchOptions( "PUT", data );
        const req = new Request( url, fetchOpts );
        const requestId = fetchOpts.signal.requestId;

        if (cb) {
            await this._fetch( req, cb, fetchOptions );
            return { requestId };
        } else {
            const result = await this._fetch( req, cb, fetchOptions );
            return { requestId, result };
        }
    }

    /**
     * PATCH call
     * @param {string} endpoint - API endpoint
     * @param {object=} [data={}] - Patch data
     * @param {function=} [cb=null] - Callback function
     * @param {object=} [options={}] - Request options
     * @param {number=} options.timeout - Request timeout in milliseconds
     * @param {number=} options.retryAttempts - Number of retry attempts
     * @param {number=} options.retryDelay - Delay between retries in milliseconds
     * @param {object=} options.queryParams - Query parameters object
     * @param {function=} options.onUploadProgress - Upload progress callback
     * @param {function=} options.onDownloadProgress - Download progress callback
     * @returns {Promise<{requestId: string, result: (any|undefined)}>} Promise with request ID and result (if no callback)
     */
    async patch( endpoint, data = {}, cb = null, options = {} )
    {
        const { queryParams, ...fetchOptions } = options;
        const url = this._buildUrl(endpoint, queryParams);
        const fetchOpts = this._createFetchOptions( "PATCH", data );
        const req = new Request( url, fetchOpts );
        const requestId = fetchOpts.signal.requestId;

        if (cb) {
            await this._fetch( req, cb, fetchOptions );
            return { requestId };
        } else {
            const result = await this._fetch( req, cb, fetchOptions );
            return { requestId, result };
        }
    }

    /**
     * Cancel a specific request by ID
     * @param {string} requestId - The request ID to cancel
     * @returns {boolean} True if request was found and cancelled, false otherwise
     */
    cancelRequest(requestId)
    {
        const controller = this._requestMap.get(requestId);
        if (controller) {
            try {
                controller.abort();
                this._requestMap.delete(requestId);
                this._controllers.delete(controller);
                return true;
            } catch (e) {
                // Fail silently and catch the AbortErrors.
            }
        }
        return false;
    }

    /**
     * Get all pending request IDs
     * @returns {Array<string>} Array of pending request IDs
     */
    getPendingRequests()
    {
        return Array.from(this._requestMap.keys());
    }

    /**
     * Check if a request is still pending
     * @param {string} requestId - The request ID to check
     * @returns {boolean} True if request is still pending
     */
    isRequestPending(requestId)
    {
        return this._requestMap.has(requestId);
    }

    /**
     * Aborts all pending requests
     */
    abortAll()
    {
        for (const controller of this._controllers)
        {
            try
            {
                controller.abort();
            }
            catch( e )
            {
                // Fail silently and catch the AbortErrors.
            }
        }
        this._controllers.clear();
        this._requestMap.clear();
    }

    /**
     * Add or update a base header
     * @param {string} name - Header name
     * @param {string} value - Header value
     */
    addHeader(name, value)
    {
        this.headers.set(name, value);
    }

    /**
     * Remove a base header
     * @param {string} name - Header name to remove
     */
    removeHeader(name)
    {
        this.headers.delete(name);
    }

    /**
     * Get all current base headers
     * @returns {Headers} Current headers object
     */
    getHeaders()
    {
        return new Headers(this.headers);
    }

    /**
     * Set multiple headers at once
     * @param {object|Headers} headers - Headers to set
     */
    setHeaders(headers)
    {
        if (headers instanceof Headers) {
            this.headers = new Headers(headers);
        } else if (typeof headers === 'object' && headers !== null) {
            this.headers = new Headers(headers);
        }
    }

    /**
     * Build query string from parameters object
     * @param {object} params - Query parameters object
     * @returns {string} Query string (without leading ?)
     */
    _buildQueryString(params)
    {
        if (!params || typeof params !== 'object') {
            return '';
        }
        
        const searchParams = new URLSearchParams();
        
        for (const [key, value] of Object.entries(params)) {
            if (value !== null && value !== undefined) {
                if (Array.isArray(value)) {
                    // Handle array values (e.g., tags: ['red', 'blue'] -> tags=red&tags=blue)
                    value.forEach(item => {
                        if (item !== null && item !== undefined) {
                            searchParams.append(key, String(item));
                        }
                    });
                } else {
                    searchParams.append(key, String(value));
                }
            }
        }
        
        return searchParams.toString();
    }

    /**
     * Build complete URL with query parameters
     * @param {string} endpoint - API endpoint
     * @param {object=} queryParams - Query parameters object
     * @returns {string} Complete URL
     */
    _buildUrl(endpoint, queryParams = null)
    {
        const trimmedEndpoint = Helper.trim(endpoint, "/");
        let url = this.url + '/' + trimmedEndpoint;
        
        if (queryParams) {
            const queryString = this._buildQueryString(queryParams);
            if (queryString) {
                url += (url.includes('?') ? '&' : '?') + queryString;
            }
        }
        
        return url;
    }

    /**
     * Create a progress tracking wrapper for upload progress
     * @param {any} body - Request body
     * @param {function=} onProgress - Progress callback
     * @returns {any} Wrapped body or original body
     * @private
     */
    _wrapBodyForProgress(body, onProgress) {
        if (!onProgress || !body) {
            return body;
        }

        // For FormData, File, or Blob, we can track upload progress via XMLHttpRequest
        if (body instanceof FormData || body instanceof File || body instanceof Blob) {
            // Return original body, we'll use XMLHttpRequest for upload progress
            return body;
        }

        // For string/JSON data, we can estimate progress
        if (typeof body === 'string') {
            const totalBytes = new Blob([body]).size;
            setTimeout(() => onProgress({ loaded: totalBytes, total: totalBytes, percent: 100 }), 0);
            return body;
        }

        return body;
    }

    /**
     * Fetch with upload progress support using XMLHttpRequest
     * @param {Request} req - Request object
     * @param {function=} onUploadProgress - Upload progress callback
     * @returns {Promise<Response>} Promise resolving to Response
     * @private
     */
    _fetchWithUploadProgress(req, onUploadProgress) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            
            // Configure upload progress
            if (onUploadProgress && xhr.upload) {
                xhr.upload.addEventListener('progress', (event) => {
                    if (event.lengthComputable) {
                        onUploadProgress({
                            loaded: event.loaded,
                            total: event.total,
                            percent: Math.round((event.loaded / event.total) * 100)
                        });
                    }
                });
            }

            // Configure response handling
            xhr.addEventListener('load', () => {
                const response = new Response(xhr.response, {
                    status: xhr.status,
                    statusText: xhr.statusText,
                    headers: xhr.getAllResponseHeaders().split('\r\n').reduce((headers, line) => {
                        const [key, value] = line.split(': ');
                        if (key && value) {
                            headers[key] = value;
                        }
                        return headers;
                    }, {})
                });
                resolve(response);
            });

            xhr.addEventListener('error', () => {
                reject(new Error('Network error'));
            });

            xhr.addEventListener('abort', () => {
                reject(new DOMException('Request aborted', 'AbortError'));
            });

            // Configure abort signal
            if (req.signal) {
                req.signal.addEventListener('abort', () => {
                    xhr.abort();
                });
            }

            // Configure request
            xhr.open(req.method, req.url);
            
            // Set headers
            for (const [key, value] of req.headers.entries()) {
                xhr.setRequestHeader(key, value);
            }

            // Send request
            req.arrayBuffer().then(body => {
                xhr.send(body);
            }).catch(reject);
        });
    }

    /**
     * Create a response with download progress tracking
     * @param {Response} response - Original response
     * @param {function=} onProgress - Progress callback
     * @returns {Response} Response with progress tracking
     * @private
     */
    _wrapResponseForProgress(response, onProgress) {
        if (!onProgress || !response.body) {
            return response;
        }

        const contentLength = response.headers.get('content-length');
        const total = contentLength ? parseInt(contentLength, 10) : null;
        let loaded = 0;

        const reader = response.body.getReader();
        const stream = new ReadableStream({
            start(controller) {
                function pump() {
                    return reader.read().then(({ done, value }) => {
                        if (done) {
                            controller.close();
                            return;
                        }

                        loaded += value.byteLength;
                        
                        // Call progress callback
                        onProgress({
                            loaded,
                            total: total || loaded,
                            percent: total ? Math.round((loaded / total) * 100) : 0
                        });

                        controller.enqueue(value);
                        return pump();
                    });
                }
                return pump();
            }
        });

        return new Response(stream, {
            status: response.status,
            statusText: response.statusText,
            headers: response.headers
        });
    }

    /**
     * Sleep utility for retry delays
     * @private
     */
    _sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /**
     * Check if error is retryable
     * @private
     */
    _isRetryableError(error) {
        // Retry on network errors, timeouts, and specific HTTP status codes
        return error.name === 'AbortError' || 
               error.message.includes('timeout') ||
               error.message.includes('NetworkError') ||
               error.message.includes('HTTP 5') || // 5xx server errors
               error.message.includes('HTTP 408') || // Request timeout
               error.message.includes('HTTP 429');  // Too many requests
    }

    /**
     * Fetch with retry mechanism and timeout handling
     * @private
     */
    async _fetchWithRetry(req, options = {}) {
        const { 
            retryAttempts = this.retryAttempts, 
            retryDelay = this.retryDelay,
            onUploadProgress,
            onDownloadProgress
        } = options;
        let lastError;

        for (let attempt = 0; attempt <= retryAttempts; attempt++) {
            try {
                let response;
                
                // Use XMLHttpRequest for upload progress or regular fetch
                if (onUploadProgress && req.body) {
                    response = await this._fetchWithUploadProgress(req, onUploadProgress);
                } else {
                    response = await fetch(req);
                }
                
                if (!response.ok) {
                    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
                    error.status = response.status;
                    
                    // Don't retry client errors (4xx) except specific ones
                    if (response.status >= 400 && response.status < 500 && 
                        response.status !== 408 && response.status !== 429) {
                        throw error;
                    }
                    
                    if (attempt < retryAttempts && this._isRetryableError(error)) {
                        lastError = error;
                        await this._sleep(retryDelay * Math.pow(2, attempt)); // Exponential backoff
                        continue;
                    }
                    
                    throw error;
                }
                
                // Wrap response for download progress if needed
                if (onDownloadProgress) {
                    response = this._wrapResponseForProgress(response, onDownloadProgress);
                }
                
                let result = null;
                const contentType = response.headers.get('content-type');
                
                if (contentType && contentType.includes('application/json')) {
                    try {
                        result = await response.json();
                    } catch (jsonErr) {
                        result = null;
                    }
                } else {
                    result = await response.text();
                }
                
                return {
                    status: response.status,
                    data: result,
                    headers: response.headers
                };
                
            } catch (error) {
                lastError = error;
                
                // Don't retry if it's the last attempt or not a retryable error
                if (attempt >= retryAttempts || !this._isRetryableError(error)) {
                    throw error;
                }
                
                // Wait before retry with exponential backoff
                await this._sleep(retryDelay * Math.pow(2, attempt));
            }
        }
        
        throw lastError;
    }

    async _fetch(req, cb = null, options = {})
    {
        const controller = req.signal.controller;
        const requestId = req.signal.requestId;
        const { timeout = this.timeout } = options;
        
        // Set up timeout
        const timeoutId = setTimeout(() => {
            controller.abort();
        }, timeout);
        
        const cleanup = () => {
            if (controller) {
                this._controllers.delete(controller);
                this._requestMap.delete(requestId);
            }
        };
        
        if (cb)
        {
            this._fetchWithRetry(req, options)
                .then(result => {
                    clearTimeout(timeoutId);
                    cb(null, result);
                })
                .catch(error => {
                    clearTimeout(timeoutId);
                    cb(error, null);
                })
                .finally(cleanup);
        }
        else
        {
            try
            {
                const result = await this._fetchWithRetry(req, options);
                clearTimeout(timeoutId);
                return result;
            }
            catch (err)
            {
                clearTimeout(timeoutId);
                throw err;
            }
            finally
            {
                cleanup();
            }
        }
    }

    _createFetchOptions( method, data = null )
    {
        const controller = new AbortController();
        const requestId = `req_${++this._requestIdCounter}_${Date.now()}`;
        
        this._controllers.add(controller);
        this._requestMap.set(requestId, controller);

        const opts = {
            method: method.toUpperCase(),
            headers: this.headers,
            signal: controller.signal
        };

        // Attach controller and request ID to signal for cleanup access
        opts.signal.controller = controller;
        opts.signal.requestId = requestId;

        if ( Helper.isPlainObject(data) )
        {
            opts.body = JSON.stringify(data);
        }

        return opts;
    }
}

export { RestApi };