/* eslint-disable no-console */

/**
 * FileHandler manages file downloads with priority-based queuing and batch processing.
 *
 * Features:
 * - Priority queue system with high/normal priority and channel-based prioritization
 * - Batch processing (10 downloads in parallel) for optimal performance
 * - Automatic cache management with cleanup of unused files
 * - Resume capability for interrupted downloads
 * - Channel-based prioritization (active channel downloads get priority)
 * - Memory and disk caching with MMKV storage
 *
 * Queue Management:
 * - High priority downloads are inserted at the front (unshift)
 * - Normal priority downloads are inserted at the end (push)
 * - Active channel downloads are automatically moved to front when channel changes
 * - Batch processing ensures UI remains responsive
 */

import {AppState, unstable_batchedUpdates} from 'react-native';
import FileViewer from 'react-native-file-viewer';
import * as ExpoFileSystem from 'expo-file-system/legacy';
import ReactNativeBlobUtil from 'react-native-blob-util';
import {Worklets} from 'react-native-worklets-core';
import i18next from 'i18next';
import throttle from 'lodash.throttle';
import {tokenManager} from '@config/tokens';
import {getApiHeaders} from '@config/api';
import {trackCustomHumandError} from '@hooks/useGeneralError';
import {ERROR_MESSAGES} from '@modules/chats/constants';
import {Asset, Message} from '@modules/chats/interfaces';
import {showSnackbar} from '@redux/events';
import {Storage} from '@services/mmkvFactory';
import {asyncMap, isIos} from '@shared/utils';

import {
  DownloadAsset,
  FileDownloadStatus,
  getFileAutomaticDownload,
  setDownloadProgress,
  updateFileState,
  subscribeFilesBatch,
  UpdateFileParams,
  updateFileStateBatch,
  subscribeFileAsDoneBatch,
  updateAssetsBatch,
} from '../store/zustand/useAttachments';
// Avoid circular dependency
import {
  isAudioAsset,
  isDocumentAsset,
  isGifAsset,
  isPictureAsset,
  isVideoAsset,
  mapAssetToUrl,
} from '../utils/mappers/assetMapper';

const showNotEnoughSpaceError = throttle(
  () => {
    showSnackbar({
      title: i18next.t('chat.errors.not_enough_space'),
      variant: 'error',
    });
  },
  2000,
  {leading: true, trailing: false},
);

const storage = new Storage({id: 'file_storage'});
const memoryCache = new Map<string, CacheData>();

// Numeric tiers, lower = sooner. Queue sorted by (priority asc, timestamp asc).
// `setViewport` re-prioritises queued tasks; in-flight tasks are never reordered.
// Images / avatars / video posters don't go through this queue at all —
// they're served by ExpoImage's own cache. Videos are streamed + cached by
// expo-video directly. Everything left here is audio files and document
// downloads.
export const DownloadPriority = {
  UserInitiated: 0,
  ForegroundVisible: 1,
  ForegroundPrefetch: 2,
  BackgroundPrefetch: 3,
} as const;
export type DownloadPriorityValue =
  (typeof DownloadPriority)[keyof typeof DownloadPriority];
// Back-compat for legacy `'high' | 'normal'` callers.
export type DownloadPriorityInput = DownloadPriorityValue | 'high' | 'normal';
const resolvePriority = (p: DownloadPriorityInput): DownloadPriorityValue => {
  if (p === 'high') return DownloadPriority.ForegroundPrefetch;
  if (p === 'normal') return DownloadPriority.BackgroundPrefetch;
  return p;
};

interface DownloadTask {
  operation: () => Promise<void>;
  channel?: string;
  priority: DownloadPriorityValue;
  timestamp: number;
  assetId: number;
}

const downloadQueue: DownloadTask[] = [];
let activeChannel: string | null = null;
let activeDownloads = 0;
const MAX_PARALLEL_DOWNLOADS = 5;
// Audio-only timeout. Original intent was to cap image/video downloads too,
// but those are now served by ExpoImage / expo-video and never enter this
// queue. Documents intentionally have no timeout — they can be arbitrarily
// large (multi-GB PDFs, archives) and a hard ceiling would surface as
// spurious "download failed" errors for long but valid transfers.
const AUDIO_DOWNLOAD_TIMEOUT_MS = 90_000;
// Auto-retry policy for transient download errors (network blip, 5xx, etc).
// Total attempts = DOWNLOAD_MAX_RETRIES (counting the initial try).
// Only after all attempts fail do we update Zustand to ERROR — so the hook
// shows the retry/download button only when the asset is genuinely stuck.
const DOWNLOAD_MAX_RETRIES = 3;
const DOWNLOAD_RETRY_DELAYS_MS = [500, 1500, 4000];

const DIACRITICS_REGEX = /[\u0300-\u036f]/;
const SPECIAL_CHARS_REGEX = /[\s+&/\\:*?"<>|#%{}[\]'`=;!]/;
const DIACRITICS_AND_SPACES_REGEX = new RegExp(
  `${DIACRITICS_REGEX.source}|${SPECIAL_CHARS_REGEX.source}`,
  'g',
);

export function getFixedFilename(filename: string) {
  return filename
    .normalize('NFD')
    .replace(DIACRITICS_AND_SPACES_REGEX, match =>
      DIACRITICS_REGEX.test(match) ? '' : '_',
    );
}

// Per-task ceiling to guard against non-HTTP stalls (fs ops, cancelAsync).
const TASK_WATCHDOG_MS = 180_000;

const queueComparator = (a: DownloadTask, b: DownloadTask) => {
  if (a.priority !== b.priority) return a.priority - b.priority;
  return a.timestamp - b.timestamp;
};

const insertTaskSorted = (task: DownloadTask) => {
  for (let i = 0; i < downloadQueue.length; i++) {
    if (queueComparator(task, downloadQueue[i]) < 0) {
      downloadQueue.splice(i, 0, task);
      return;
    }
  }
  downloadQueue.push(task);
};

const enqueueDownload = ({
  operation,
  channel,
  priority,
  assetId,
}: Omit<DownloadTask, 'timestamp' | 'priority'> & {
  priority: DownloadPriorityInput;
}) => {
  const resolvedPriority = resolvePriority(priority);
  const task: DownloadTask = {
    operation,
    channel,
    priority: resolvedPriority,
    timestamp: Date.now(),
    assetId,
  };

  insertTaskSorted(task);
  pumpDownloadQueue();
};

// Worker pool: up to MAX_PARALLEL_DOWNLOADS tasks run concurrently.
const pumpDownloadQueue = (): void => {
  while (activeDownloads < MAX_PARALLEL_DOWNLOADS && downloadQueue.length > 0) {
    const task = downloadQueue.shift();
    if (!task) break;
    activeDownloads++;
    (async () => {
      let watchdog: ReturnType<typeof setTimeout> | undefined;
      try {
        await Promise.race([
          task.operation(),
          new Promise((_, reject) => {
            watchdog = setTimeout(() => {
              reject(
                new Error(`Task watchdog fired after ${TASK_WATCHDOG_MS}ms`),
              );
            }, TASK_WATCHDOG_MS);
          }),
        ]);
      } catch {
        // Individual task errors are handled inside the operation.
      } finally {
        if (watchdog !== undefined) {
          clearTimeout(watchdog);
        }
        activeDownloads--;
        pumpDownloadQueue();
      }
    })();
  }
};

// Legacy queue for non-download operations
const queue: (() => void)[] = [];
let isProcessing = false;

const enqueue = (operation: () => void) => {
  queue.push(operation);
  if (!isProcessing) processQueue();
};

const processQueue = () => {
  if (queue.length === 0) {
    isProcessing = false;
    return;
  }

  isProcessing = true;
  const next = queue.shift();
  if (next) {
    setTimeout(() => {
      next();
      processQueue();
    }, 0); // We want to queue the operation in the event loop for sync operations that we want to be async
  }
};

const BATCH_SIZE = 200;
const MAX_DAYS_UNUSED_MS = 1000 * 60 * 60 * 24 * 14; // MS default for 14 days
const MAX_UNUSED_DAY_MS = 1000 * 60 * 60 * 24; // MS default for 24 hours
const MIN_FREE_SPACE_BYTES = 157_286_400; // 150 MB in bytes (150 * 1024 * 1024) - minimum free space before cleaning up old files with forceDelete
enum ForceDeleteStrategy {
  ALL_FILES = 'ALL_FILES',
  ONE_DAY = 'ONE_DAY',
  MAX_DAYS = 'MAX_DAYS',
}

const timeMsByStrategy = {
  [ForceDeleteStrategy.ALL_FILES]: 0,
  [ForceDeleteStrategy.ONE_DAY]: MAX_UNUSED_DAY_MS,
  [ForceDeleteStrategy.MAX_DAYS]: MAX_DAYS_UNUSED_MS,
};
const mmkvTuples = Worklets.createSharedValue([] as [string, string][]);
export const fileStorage = {
  getSync(key: string): CacheData | undefined {
    if (memoryCache.has(key)) return memoryCache.get(key);
    const val = storage.getString(key);
    if (val) {
      const parsedVal = JSON.parse(val) as CacheData;
      memoryCache.set(key, parsedVal);
      return parsedVal;
    }
  },

  set(key: string, value: CacheData): Promise<void> {
    memoryCache.set(key, value);
    return new Promise(resolve => {
      enqueue(() => {
        storage.set(key, JSON.stringify(value));
        resolve();
      });
    });
  },

  clear(key: string): Promise<void> {
    return new Promise(resolve => {
      enqueue(() => {
        storage.remove(key);
        memoryCache.delete(key);
        resolve();
      });
    });
  },

  async getAllBatchedAndReturnFilesToDelete(
    strategy: ForceDeleteStrategy = ForceDeleteStrategy.MAX_DAYS,
  ): Promise<string[]> {
    const keys = storage.getAllKeys();
    const keyValuePairs = keys.map(key => {
      const val = storage.getString(key);
      if (val) return [key, val] as [string, string];
      return [key, ''] as [string, string];
    });

    const context = Worklets.defaultContext;
    const allFilesToDelete: string[] = [];
    const finalUnusedTimeMs = timeMsByStrategy[strategy] ?? 0;
    for (let i = 0; i < keyValuePairs.length; i += BATCH_SIZE) {
      const batch = keyValuePairs.slice(i, i + BATCH_SIZE);
      mmkvTuples.value = batch;
      const {parsedBatch, filesToDelete} = await context.runAsync(() => {
        'worklet';
        const parsedBatchInternal: [string, CacheData][] = [];
        const filesToDeleteInternal: string[] = [];
        for (const [key, jsonString] of mmkvTuples.value) {
          let parsedJson: CacheData | null = null;
          try {
            parsedJson = JSON.parse(jsonString) as CacheData;
          } catch (e) {
            filesToDeleteInternal.push(key);
            console.log('Error parsing JSON', e);
          }

          if (parsedJson) {
            parsedBatchInternal.push([key, parsedJson]);

            if (parsedJson.lastTimeUsed) {
              if (parsedJson.lastTimeUsed < Date.now() - finalUnusedTimeMs) {
                filesToDeleteInternal.push(key);
              }
            } else {
              filesToDeleteInternal.push(key);
            }
          }
        }
        return {
          parsedBatch: parsedBatchInternal,
          filesToDelete: filesToDeleteInternal,
        };
      });
      // Populate memory incrementally
      parsedBatch.forEach(([key, value]) => {
        if (!value.lastTimeUsed) {
          value.lastTimeUsed = Date.now();
          storage.set(key, JSON.stringify(value));
        }
        memoryCache.set(key, value);
      });
      allFilesToDelete.push(...filesToDelete);

      // Queue in event loop
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    return allFilesToDelete;
  },

  clearCache(): void {
    storage.clearAll();
    memoryCache.clear();
  },
};

/**
 * Interface representing cached file data
 * @interface CacheData
 * @property {number} key - Unique identifier for the cached file
 * @property {string} uri - Local URI or externalURL where the file is stored
 * @property {string} messageId - ID of the message associated with this file
 * @property {Nullable<DownloadPauseState>} savable - Download state information that can be saved to resume downloads
 */
interface CacheData {
  key: number;
  uri: string;
  remoteUrl: string;
  messageId: string;
  channel: string;
  filename: string;
  savable: Nullable<ExpoFileSystem.DownloadPauseState>;
  lastTimeUsed?: number;
}

type GetFileParams = {
  assetId: number;
  url: string;
  filename: string;
  messageId: string;
  shouldDownload?: boolean;
  updateStateWhenNotDownloaded?: UpdateFileParams;
  asset: Asset;
  channel: string;
};

export type MessageMetadata = Pick<Message, 'hidden' | 'channel'>;

export type ProcessAttachmentParamItem = {
  attachments: Asset[];
  messageId: string;
  messageMetadata: MessageMetadata;
};

/** S3 ETag checksum validation; MD5 here is for integrity only, not cryptographic use */
function isRealS3MD5(etag: string) {
  if (!etag) return false;

  // Weak ETag
  if (etag.startsWith('W/')) return false;

  // Multipart upload: contiene un guion
  if (etag.includes('-')) return false;

  // Debe tener exactamente 32 chars hexadecimales (MD5)
  return /^[a-fA-F0-9]{32}$/.test(etag);
}

export class FileHandler {
  private static instance: FileHandler;
  private documentDirectory: string = '';
  private currentDownloads: Record<
    number,
    Nullable<ExpoFileSystem.DownloadResumable>
  > = {};
  // UI ref-count per asset; at zero we cancel the queued/in-flight download.
  private subscriberCounts = new Map<number, number>();
  // This is used to avoid multiple downloads of the same file in the same time
  private memoryIsDownloading = new Map<number, boolean>();
  // Per-asset waiters: when a second messageId asks for an in-flight asset,
  // it parks here and gets notified by the originating download's outcome
  // (so its own messageId entry in Zustand is also written DONE/ERROR).
  private inflightWaiters = new Map<
    number,
    Array<{messageId: string; resolve: (uri?: string) => void}>
  >();
  private hasAlreadyCheckFreeSpace = false;
  private constructor() {
    fileStorage
      .getAllBatchedAndReturnFilesToDelete(ForceDeleteStrategy.MAX_DAYS)
      .then(filesToDelete => {
        this.cleanupOldFiles(filesToDelete);
      });
    this.ensureMainDirectory();
    this.initializeListeners();
  }

  private cleanupOldFiles(filesToDelete: string[]) {
    filesToDelete.forEach(file => {
      this.clearAsset(Number(file));
    });
  }

  public static getInstance(): FileHandler {
    if (!FileHandler.instance) {
      FileHandler.instance = new FileHandler();
    }
    return FileHandler.instance;
  }

  // Active channel → ForegroundPrefetch, other channels → BackgroundPrefetch.
  // UserInitiated tasks are never touched.
  public setActiveChannel(channel: string | null): void {
    activeChannel = channel;
    if (!channel) return;

    let changed = false;
    for (const task of downloadQueue) {
      if (task.priority === DownloadPriority.UserInitiated) continue;
      const target =
        task.channel === channel
          ? DownloadPriority.ForegroundPrefetch
          : DownloadPriority.BackgroundPrefetch;
      if (task.priority !== target) {
        task.priority = target;
        changed = true;
      }
    }
    if (changed) {
      downloadQueue.sort(queueComparator);
      pumpDownloadQueue();
    }
  }

  // Visible tasks in `channel` → ForegroundVisible, others in channel →
  // ForegroundPrefetch. Empty `visibleAssetIds` is a no-op.
  public setViewport(
    channel: string,
    visibleAssetIds: ReadonlyArray<number>,
  ): void {
    if (visibleAssetIds.length === 0) return;
    const visible = new Set(visibleAssetIds);
    let changed = false;
    for (const task of downloadQueue) {
      if (task.channel !== channel) continue;
      if (task.priority === DownloadPriority.UserInitiated) continue;
      const target = visible.has(task.assetId)
        ? DownloadPriority.ForegroundVisible
        : DownloadPriority.ForegroundPrefetch;
      if (task.priority !== target) {
        task.priority = target;
        changed = true;
      }
    }
    if (changed) {
      downloadQueue.sort(queueComparator);
      pumpDownloadQueue();
    }
  }

  private initializeListeners() {
    AppState.addEventListener('change', this.handleAppStateChange);
  }

  private handleAppStateChange = (nextAppState: string) => {
    if (nextAppState === 'background') {
      this.pauseDownloads();
    } else if (nextAppState === 'active') {
      this.resumeDownloads();
    }
  };

  private resumableCallback =
    (key: number, messageId: string) =>
    (progress: ExpoFileSystem.DownloadProgressData) => {
      setDownloadProgress({
        assetId: key,
        messageId,
        progress:
          progress.totalBytesWritten / progress.totalBytesExpectedToWrite,
      });
    };

  private async checkDownloadResult(
    result?: ExpoFileSystem.FileSystemDownloadResult,
  ) {
    if (!result || String(result?.status)[0] !== '2') {
      return false;
    }

    // Android and iOS has different casing
    const serverFileTag =
      result.headers.etag || result.headers.Etag || result.headers.ETag;

    if (!serverFileTag) {
      return true;
    }

    const serverFileHashSum = serverFileTag.replace(/"/g, ''); // MD5 hash from server

    if (!isRealS3MD5(serverFileHashSum)) {
      return true;
    }

    // Function to get valid URI (decoded if necessary)
    // If the file_name is Cyrillic, it should be decoded to check its integrity
    const getValidUri = async (uri: string) => {
      const preparedUri = uri.replace('file://', '');
      try {
        const exists = await ReactNativeBlobUtil.fs.exists(preparedUri);
        if (exists) {
          return preparedUri;
        }
        // Try to decode URI
        return decodeURI(preparedUri);
      } catch (e) {
        return decodeURI(preparedUri);
      }
    };

    // Check file integrity
    try {
      const uri = await getValidUri(result.uri);

      const localFileHashSum = await ReactNativeBlobUtil.fs.hash(uri, 'md5');

      return serverFileHashSum === localFileHashSum;
    } catch (e) {
      return false;
    }
  }

  private async pauseDownloads() {
    for (const [key, downloadResumable] of Object.entries(
      this.currentDownloads,
    )) {
      if (downloadResumable) {
        try {
          await downloadResumable.pauseAsync();
          const newCachedData = fileStorage.getSync(key);
          if (newCachedData) {
            newCachedData.savable = downloadResumable.savable();
            fileStorage.set(key, newCachedData);
          }
        } catch (error) {
          /* empty */
        }
        delete this.currentDownloads[Number(key)];
      }
    }
  }

  public async cancelAllDownloads(): Promise<void> {
    try {
      downloadQueue.splice(0, downloadQueue.length);

      await Promise.all(
        Object.entries(this.currentDownloads).map(
          async ([key, downloadResumable]) => {
            if (!downloadResumable) {
              return;
            }
            try {
              await downloadResumable.cancelAsync();
              const savable = downloadResumable.savable();
              if (savable?.fileUri) {
                ExpoFileSystem.deleteAsync(savable.fileUri, {
                  idempotent: true,
                }).catch(() => {});
              }
            } catch (error) {
              console.log('Cancel download error', error);
            } finally {
              delete this.currentDownloads[Number(key)];
              this.memoryIsDownloading.delete(Number(key));
            }
          },
        ),
      );

      const clearSavablesPromises: Promise<void>[] = [];
      memoryCache.forEach((cachedData, assetId) => {
        if (cachedData.savable) {
          cachedData.savable = null;
          clearSavablesPromises.push(
            fileStorage.set(String(assetId), cachedData),
          );
        }
      });

      await Promise.all(clearSavablesPromises);

      // Notify any in-flight waiters with `undefined` so they unblock
      // downstream consumers instead of dangling forever.
      for (const assetId of Array.from(this.inflightWaiters.keys())) {
        this.notifyInflightWaiters(assetId, undefined);
      }
    } catch (error) {
      console.log('Cancel all downloads error', error);
    }
  }

  private getDeleteStrategyByRetryNumber(
    retryNumber: number,
  ): ForceDeleteStrategy {
    return retryNumber === 0
      ? ForceDeleteStrategy.ONE_DAY
      : ForceDeleteStrategy.ALL_FILES;
  }

  private async handleENOSPCError(
    retryNumber: number,
    freeDiskCheck?: boolean,
  ) {
    const freeDiskStorage = await ExpoFileSystem.getFreeDiskStorageAsync();
    trackCustomHumandError(
      {
        code: 'ENOSPC',
        message: freeDiskCheck
          ? 'No space left on device cause of free disk storage check lower than 150MB'
          : `No space left on device. Retry number: ${retryNumber}`,
        freeSpace: freeDiskStorage,
      },
      'CustomFileHandlerError',
    );
    return fileStorage
      .getAllBatchedAndReturnFilesToDelete(
        this.getDeleteStrategyByRetryNumber(retryNumber),
      )
      .then(filesToDelete => {
        this.cleanupOldFiles(filesToDelete);
      });
  }
  private async resumeDownload(cachedData: CacheData, retryNumber: number = 0) {
    if (this.memoryIsDownloading.get(cachedData.key)) {
      return;
    }
    this.memoryIsDownloading.set(cachedData.key, true);
    try {
      if (this.currentDownloads[cachedData.key]) {
        return;
      }
      const downloadSnapshot = cachedData.savable!; // This function will execute only if the savable is not null
      const downloadResumable = new ExpoFileSystem.DownloadResumable(
        downloadSnapshot.url,
        downloadSnapshot.fileUri,
        downloadSnapshot.options,
        this.resumableCallback(cachedData.key, cachedData.messageId),
        downloadSnapshot.resumeData,
      );
      this.currentDownloads[cachedData.key] = downloadResumable;
      const result = await downloadResumable.resumeAsync();
      const is2xx = await this.checkDownloadResult(result);
      if (result?.uri && is2xx) {
        delete this.currentDownloads[cachedData.key];

        updateFileState({
          assetId: cachedData.key,
          messageId: cachedData.messageId,
          state: FileDownloadStatus.DONE,
        });

        fileStorage.set(String(cachedData.key), {
          key: cachedData.key,
          uri: result.uri,
          remoteUrl: cachedData.remoteUrl,
          filename: cachedData.filename,
          savable: null,
          messageId: cachedData.messageId,
          lastTimeUsed: Date.now(),
          channel: cachedData.channel,
        });
      } else {
        updateFileState({
          assetId: cachedData.key,
          messageId: cachedData.messageId,
          state: FileDownloadStatus.ERROR,
        });
      }
    } catch (error: any) {
      const message = error?.message ?? String(error);
      if (
        message.includes('ENOSPC') ||
        message.includes('No space left on device')
      ) {
        if (retryNumber >= 2) {
          showNotEnoughSpaceError();
          this.clearAsset(cachedData.key);
          updateFileState({
            assetId: cachedData.key,
            messageId: cachedData.messageId,
            state: FileDownloadStatus.ERROR,
          });
        } else {
          this.handleENOSPCError(retryNumber).then(() => {
            this.resumeDownload(cachedData, retryNumber + 1);
          });
        }
      } else if (
        message.includes('ENOENT') ||
        message.includes('No such file')
      ) {
        // Temporary file was deleted (cache cleared), start fresh download
        this.clearAsset(cachedData.key);
        delete this.currentDownloads[cachedData.key];
        // Reset state to IDLE so UI shows download is restarting
        updateFileState({
          assetId: cachedData.key,
          messageId: cachedData.messageId,
          state: FileDownloadStatus.IDLE,
        });
        const priority =
          cachedData.channel === activeChannel ? 'high' : 'normal';
        enqueueDownload({
          operation: async () =>
            await this.startDownload({
              assetId: cachedData.key,
              url: cachedData.remoteUrl,
              filename: cachedData.filename,
              messageId: cachedData.messageId,
              channel: cachedData.channel,
              // CacheData doesn't carry asset type. Resume paths are
              // rare and only fire for actually-paused downloads; treat
              // as "no timeout" rather than risk failing a slow audio
              // resume on the audio ceiling.
              isAudio: false,
            }),
          channel: cachedData.channel,
          priority,
          assetId: cachedData.key,
        });
      } else {
        this.clearAsset(cachedData.key);
        console.log('Resume error', error);
        updateFileState({
          assetId: cachedData.key,
          messageId: cachedData.messageId,
          state: FileDownloadStatus.ERROR,
        });
      }
    }
    this.removeMemoryIsDownloadingItem(cachedData.key);
  }

  private resumeDownloads() {
    memoryCache.forEach((cachedData, assetId) => {
      if (cachedData.savable && !this.currentDownloads[Number(assetId)]) {
        // We don't have channel info for resumed downloads, so use normal priority
        const priority = 'normal';
        enqueueDownload({
          operation: async () => await this.resumeDownload(cachedData),
          channel: undefined, // No channel info available for resumed downloads
          priority,
          assetId: Number(assetId),
        });
      }
    });
  }

  private async startDownload(
    {
      assetId,
      url,
      filename,
      messageId,
      channel: _channel,
      isAudio,
    }: {
      assetId: number;
      url: string;
      filename: string;
      messageId: string;
      channel: string;
      // True for audio, false for documents. Drives whether we cap the
      // download with a hard timeout — only audio has one.
      isAudio: boolean;
    },
    retryNumber: number = 0,
  ): Promise<void> {
    if (this.memoryIsDownloading.get(assetId)) {
      // Another messageId is already downloading this asset (forwarded
      // message case). Park as a waiter — the in-flight download's
      // notifyInflightWaiters will write DONE/ERROR onto OUR messageId
      // entry when it completes.
      await this.registerInflightWaiter(assetId, messageId);
      return;
    }
    this.memoryIsDownloading.set(assetId, true);
    try {
      const directory = await this.createDirectory(assetId);
      const filePath = directory + filename;
      // Short-circuit if the file is already on disk (e.g. an earlier
      // duplicate enqueue completed first).
      const alreadyOnDisk = await this.checkIfFileExists(filePath);
      if (alreadyOnDisk) {
        updateFileState({
          assetId,
          messageId,
          state: FileDownloadStatus.DONE,
        });
        this.memoryIsDownloading.delete(assetId);
        this.notifyInflightWaiters(assetId, filePath);
        return;
      }
      await this.removeFileIfExists(filePath);
      const token = await tokenManager.getValidAccessToken();

      if (!token) {
        throw new Error('No access token found');
      }

      const downloadResumable = ExpoFileSystem.createDownloadResumable(
        url,
        filePath,
        {
          headers: getApiHeaders(token),
        },
        this.resumableCallback(assetId, messageId),
      );
      this.currentDownloads[assetId] = downloadResumable;
      const result = await this.downloadWithTimeout(
        downloadResumable,
        isAudio ? AUDIO_DOWNLOAD_TIMEOUT_MS : null,
      );
      const newCachedData = {
        key: assetId,
        uri: filePath,
        remoteUrl: url,
        savable: null,
        messageId,
        filename,
        lastTimeUsed: Date.now(),
        channel: _channel,
      };
      fileStorage.set(String(assetId), newCachedData);
      const is2xx = await this.checkDownloadResult(result);
      if (result?.uri && is2xx) {
        fileStorage.set(String(assetId), {
          key: assetId,
          uri: result.uri,
          remoteUrl: url,
          savable: null,
          messageId: newCachedData.messageId,
          filename,
          lastTimeUsed: Date.now(),
          channel: _channel,
        });
        delete this.currentDownloads[assetId];

        updateFileState({
          assetId,
          state: FileDownloadStatus.DONE,
          messageId,
        });
        this.notifyInflightWaiters(assetId, result.uri);
      } else {
        // Fall through to the catch block so the retry logic decides
        // whether to try again or commit the ERROR state.
        throw new Error(`Download failed with status: ${result?.status}`);
      }
    } catch (error: any) {
      const message = error?.message ?? String(error);
      const isENOSPC =
        message.includes('ENOSPC') ||
        message.includes('No space left on device');

      if (isENOSPC) {
        if (retryNumber >= 2) {
          showNotEnoughSpaceError();
          this.clearAsset(assetId);
          updateFileState({
            assetId,
            state: FileDownloadStatus.ERROR,
            messageId,
          });
          this.notifyInflightWaiters(assetId, undefined);
        } else {
          this.handleENOSPCError(retryNumber).then(() => {
            this.startDownload(
              {assetId, url, filename, messageId, channel: _channel, isAudio},
              retryNumber + 1,
            );
          });
        }
      } else if (retryNumber + 1 < DOWNLOAD_MAX_RETRIES) {
        // Generic transient error — wait + recurse, leaving Zustand in its
        // current state so the UI doesn't flash ERROR mid-retry.
        const delay =
          DOWNLOAD_RETRY_DELAYS_MS[retryNumber] ??
          DOWNLOAD_RETRY_DELAYS_MS[DOWNLOAD_RETRY_DELAYS_MS.length - 1];
        this.removeMemoryIsDownloadingItem(assetId);
        await new Promise<void>(r => setTimeout(r, delay));
        return this.startDownload(
          {assetId, url, filename, messageId, channel: _channel, isAudio},
          retryNumber + 1,
        );
      } else {
        this.clearAsset(assetId);
        updateFileState({
          assetId,
          state: FileDownloadStatus.ERROR,
          messageId,
        });
        this.notifyInflightWaiters(assetId, undefined);
      }
    }
    this.removeMemoryIsDownloadingItem(assetId);
  }

  public setFileBatch(
    files: {
      assetId: number;
      uri: string;
      remoteUrl: string;
      messageId: string;
      filename: string;
      asset: Asset;
      channel: string;
    }[],
  ): void {
    subscribeFileAsDoneBatch(files);

    for (const {
      assetId,
      uri,
      remoteUrl,
      messageId,
      filename,
      channel,
    } of files) {
      const newCachedData = {
        key: assetId,
        uri,
        remoteUrl,
        savable: null,
        messageId,
        filename: getFixedFilename(filename),
        lastTimeUsed: Date.now(),
        channel,
      };
      fileStorage.set(String(assetId), newCachedData);
    }
  }

  public async getFilesBatch(data: GetFileParams[]): Promise<void> {
    const registerDownloadsArray: DownloadAsset[] = [];
    const downloadUpdates: UpdateFileParams[] = [];
    const actions: (() => void)[] = [];

    await asyncMap(
      data,
      async ({
        assetId,
        url,
        filename,
        messageId,
        shouldDownload,
        updateStateWhenNotDownloaded,
        asset,
        channel,
      }) => {
        const cachedData = fileStorage.getSync(String(assetId));
        const isAudio = isAudioAsset(asset);

        if (!cachedData) {
          registerDownloadsArray.push({
            assetId,
            uri: `${this.documentDirectory}${assetId}/${filename}`,
            remoteUrl: url,
            messageId,
            filename,
            asset,
          });
          updateStateWhenNotDownloaded &&
            downloadUpdates.push(updateStateWhenNotDownloaded);
          actions.push(() => {
            if (shouldDownload) {
              // Determine priority based on active channel
              const priority = channel === activeChannel ? 'high' : 'normal';
              enqueueDownload({
                operation: async () =>
                  await this.startDownload({
                    assetId,
                    url,
                    filename,
                    messageId,
                    channel,
                    isAudio,
                  }),
                channel,
                priority,
                assetId,
              });
            }
          });
        } else {
          if (!!cachedData.savable && !this.currentDownloads[Number(assetId)]) {
            registerDownloadsArray.push({
              assetId,
              uri: cachedData.uri,
              remoteUrl: cachedData.remoteUrl,
              messageId,
              filename: cachedData.filename,
              asset,
            });
            actions.push(() => {
              // Determine priority based on active channel
              const priority = channel === activeChannel ? 'high' : 'normal';
              enqueueDownload({
                operation: async () => await this.resumeDownload(cachedData),
                channel,
                priority,
                assetId,
              });
            });
          } else {
            const exists = await this.checkIfFileExists(cachedData.uri);
            if (exists) {
              cachedData.lastTimeUsed = Date.now();
              fileStorage.set(String(assetId), cachedData);
              registerDownloadsArray.push({
                assetId,
                uri: cachedData.uri,
                remoteUrl: cachedData.remoteUrl,
                messageId,
                filename: cachedData.filename,
                asset,
              });
              downloadUpdates.push({
                assetId,
                messageId,
                state: FileDownloadStatus.DONE,
              });
            } else {
              registerDownloadsArray.push({
                assetId,
                uri: `${this.documentDirectory}${assetId}/${filename}`,
                remoteUrl: url,
                messageId,
                filename,
                asset,
              });

              actions.push(() => {
                if (shouldDownload) {
                  // Determine priority based on active channel
                  const priority =
                    channel === activeChannel ? 'high' : 'normal';
                  enqueueDownload({
                    operation: async () =>
                      await this.startDownload({
                        assetId,
                        url,
                        filename,
                        messageId,
                        channel,
                        isAudio,
                      }),
                    channel,
                    priority,
                    assetId,
                  });
                }
              });
            }
          }
        }
      },
    );

    unstable_batchedUpdates(() => {
      subscribeFilesBatch(registerDownloadsArray);
      updateFileStateBatch(downloadUpdates);
    });

    actions.forEach(fn => fn());
  }

  private async ensureMainDirectory(): Promise<void> {
    try {
      if (ExpoFileSystem.documentDirectory) {
        this.documentDirectory = ExpoFileSystem.documentDirectory + 'files/';
      }

      const result = await ExpoFileSystem.getInfoAsync(this.documentDirectory);
      if (!result.exists || !result.isDirectory) {
        await ExpoFileSystem.makeDirectoryAsync(this.documentDirectory, {
          intermediates: true,
        });
      }
    } catch (error) {
      // Failed to create cache directory
    }
  }

  // Notify everyone waiting on this asset's in-flight download. Each waiter
  // gets its own messageId's Zustand entry updated so downstream consumers
  // (forwarded messages, multi-message reuse) see the same DONE/ERROR.
  private notifyInflightWaiters(assetId: number, uri: string | undefined) {
    const waiters = this.inflightWaiters.get(assetId);
    if (!waiters || waiters.length === 0) return;
    this.inflightWaiters.delete(assetId);
    for (const w of waiters) {
      updateFileState({
        assetId,
        messageId: w.messageId,
        state: uri ? FileDownloadStatus.DONE : FileDownloadStatus.ERROR,
      });
      w.resolve(uri);
    }
  }

  private registerInflightWaiter(
    assetId: number,
    messageId: string,
  ): Promise<string | undefined> {
    return new Promise(resolve => {
      const list = this.inflightWaiters.get(assetId) ?? [];
      list.push({messageId, resolve});
      this.inflightWaiters.set(assetId, list);
    });
  }

  private async downloadWithTimeout(
    downloadResumable: ExpoFileSystem.DownloadResumable,
    timeoutMs: number | null,
  ) {
    // Documents skip the timeout entirely — they can be arbitrarily large,
    // and racing them against a setTimeout produces false "failed" errors
    // for slow-but-legitimate transfers. Only audio uses a ceiling.
    if (timeoutMs == null) {
      return downloadResumable.downloadAsync();
    }
    let timeoutId: ReturnType<typeof setTimeout> | undefined;
    const timeoutPromise = new Promise<never>((_, reject) => {
      timeoutId = setTimeout(() => {
        downloadResumable.cancelAsync().catch(() => {});
        reject(new Error(`Download timed out after ${timeoutMs}ms`));
      }, timeoutMs);
    });

    try {
      return await Promise.race([
        downloadResumable.downloadAsync(),
        timeoutPromise,
      ]);
    } finally {
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
    }
  }

  private removeMemoryIsDownloadingItem(assetId: number) {
    /* 
      To avoid multiple downloads of the same file in the same time when we initiated many downloads for the same assetId
      we should remove the id from the memoryIsDownloading after we finished all download tasks
      -- put the task to the macrotask queue to ensure all download tasks are finished
    */
    setTimeout(() => {
      this.memoryIsDownloading.delete(assetId);
    }, 0);
  }

  public async checkIfFileExists(url: string): Promise<boolean> {
    try {
      if (url.includes('https://')) {
        //If the url is a https url, we don't need to check if the file exists in the cache cause it does not
        return false;
      }

      let isDir = false;

      // Remove file:// prefix and decode URL-encoded characters
      let preparedUrl = isIos ? url.replace('file://', '') : url;
      preparedUrl = decodeURIComponent(preparedUrl);

      const exists = await ReactNativeBlobUtil.fs.exists(preparedUrl);

      if (exists) {
        isDir = await ReactNativeBlobUtil.fs.isDir(preparedUrl);
      }

      return exists && !isDir;
    } catch (error) {
      return false;
    }
  }

  private async createDirectory(assetId: number) {
    const directory = `${this.documentDirectory}${assetId}/`;
    await ExpoFileSystem.makeDirectoryAsync(directory, {intermediates: true});
    return directory;
  }

  private async removeFileIfExists(filePath: string) {
    // Check if file already exists and delete it to ensure fresh download
    const fileExists = await this.checkIfFileExists(filePath);
    if (fileExists) {
      try {
        await ExpoFileSystem.deleteAsync(filePath, {idempotent: true});
      } catch (error) {
        console.log('Error deleting existing file', error);
      }
    }
  }

  public downloadRemoteFile(
    {assetId, url, filename, messageId, channel, asset}: GetFileParams,
    priority: DownloadPriorityInput = DownloadPriority.ForegroundVisible,
  ): Promise<string | void> {
    const isAudio = isAudioAsset(asset);
    return new Promise((resolve, reject) => {
      enqueueDownload({
        operation: async () => {
          try {
            const result = await this.executeDownloadRemoteFile({
              assetId,
              url,
              filename: getFixedFilename(filename),
              messageId,
              channel,
              isAudio,
            });
            resolve(result);
          } catch (error) {
            reject(error);
          }
        },
        channel,
        priority,
        assetId,
      });
    });
  }

  private async executeDownloadRemoteFile(
    {
      assetId,
      url,
      filename,
      messageId,
      channel,
      isAudio,
    }: {
      assetId: number;
      url: string;
      filename: string;
      messageId: string;
      channel: string;
      // True for audio (90 s timeout cap); false for documents (no timeout).
      isAudio: boolean;
    },
    retryNumber: number = 0,
  ): Promise<string | void> {
    if (this.memoryIsDownloading.get(assetId)) {
      return this.registerInflightWaiter(assetId, messageId);
    }
    this.memoryIsDownloading.set(assetId, true);
    try {
      const directory = await this.createDirectory(assetId);
      const filePath = directory + filename;
      // Same file-on-disk short-circuit as startDownload — handles the case
      // where an earlier duplicate enqueue already downloaded this asset.
      const alreadyOnDisk = await this.checkIfFileExists(filePath);
      if (alreadyOnDisk) {
        updateFileState({
          assetId,
          messageId,
          state: FileDownloadStatus.DONE,
        });
        this.memoryIsDownloading.delete(assetId);
        this.notifyInflightWaiters(assetId, filePath);
        return filePath;
      }
      await this.removeFileIfExists(filePath);

      const token = await tokenManager.getValidAccessToken();
      if (!token) {
        throw new Error('No access token found');
      }

      const downloadResumable = ExpoFileSystem.createDownloadResumable(
        url,
        filePath,
        {
          headers: getApiHeaders(token),
        },
      );

      this.currentDownloads[assetId] = downloadResumable;
      const result = await this.downloadWithTimeout(
        downloadResumable,
        isAudio ? AUDIO_DOWNLOAD_TIMEOUT_MS : null,
      );

      const newCachedData = {
        key: assetId,
        uri: filePath,
        remoteUrl: url,
        savable: null,
        messageId,
        filename,
        lastTimeUsed: Date.now(),
        channel,
      };
      fileStorage.set(String(assetId), newCachedData);

      const is2xx = await this.checkDownloadResult(result);

      if (result?.uri && is2xx) {
        updateFileState({
          assetId,
          state: FileDownloadStatus.DONE,
          messageId,
        });
        fileStorage.set(String(assetId), {
          key: assetId,
          uri: result.uri,
          remoteUrl: url,
          savable: null,
          messageId: newCachedData.messageId,
          filename,
          lastTimeUsed: Date.now(),
          channel,
        });
        delete this.currentDownloads[assetId];
        this.removeMemoryIsDownloadingItem(assetId);
        this.notifyInflightWaiters(assetId, result.uri);
        return result.uri;
      } else {
        throw new Error(`Download failed with status: ${result?.status}`);
      }
    } catch (error: any) {
      this.memoryIsDownloading.delete(assetId);
      const message = error?.message ?? String(error);
      const isENOSPC =
        message.includes('ENOSPC') ||
        message.includes('No space left on device');

      if (isENOSPC) {
        if (retryNumber >= 2) {
          showNotEnoughSpaceError();
          this.clearAsset(assetId);
          updateFileState({
            assetId,
            state: FileDownloadStatus.ERROR,
            messageId,
          });
          this.notifyInflightWaiters(assetId, undefined);
          throw error;
        }
        // ENOSPC: clean up disk then recurse. Keep existing fire-and-forget
        // shape (the caller's promise rejects on this attempt; a background
        // cleanup + retry runs separately).
        this.handleENOSPCError(retryNumber).then(() => {
          this.executeDownloadRemoteFile(
            {assetId, url, filename, messageId, channel, isAudio},
            retryNumber + 1,
          );
        });
        throw error;
      }

      // Generic transient error. Retry up to DOWNLOAD_MAX_RETRIES times
      // with back-off before giving up and setting the Zustand ERROR state.
      // We deliberately do NOT call updateFileState(ERROR) between
      // attempts — that would re-trigger useChatFile's getValidUrl effect
      // and flip the hook to `error` state mid-retry, causing the UI to
      // flash the retry button before we've actually given up.
      if (retryNumber + 1 < DOWNLOAD_MAX_RETRIES) {
        const delay =
          DOWNLOAD_RETRY_DELAYS_MS[retryNumber] ??
          DOWNLOAD_RETRY_DELAYS_MS[DOWNLOAD_RETRY_DELAYS_MS.length - 1];
        await new Promise<void>(r => setTimeout(r, delay));
        return this.executeDownloadRemoteFile(
          {assetId, url, filename, messageId, channel, isAudio},
          retryNumber + 1,
        );
      }

      this.clearAsset(assetId);
      updateFileState({
        assetId,
        state: FileDownloadStatus.ERROR,
        messageId,
      });
      this.notifyInflightWaiters(assetId, undefined);
      throw error;
    }
  }

  public async processAttachmentsBatch(
    data: ProcessAttachmentParamItem[],
  ): Promise<void> {
    const fileAutomaticDownload = getFileAutomaticDownload();
    // Images + videos still need a Zustand entry so consumers
    // (gallery, tile, `useMediaAsset`) can look up the asset by
    // (messageId, assetId) — but they never enter the download queue.
    // We register them directly here and skip the `acc.push` so they
    // don't pay for the fileStorage check + actions wiring in
    // `getFilesBatch`.
    const staticAssetRegistrations: DownloadAsset[] = [];
    const preparedData = data.reduce<GetFileParams[]>(
      (acc, {attachments, messageId, messageMetadata}) => {
        for (const asset of attachments) {
          if (messageMetadata.hidden) {
            this.clearAsset(asset.id);
          }

          // GIFs are handled at the attachment level (validateGifs is
          // currently disabled — see the commented-out method below) so
          // they're skipped here entirely.
          if (isGifAsset(asset)) continue;

          const isImage = isPictureAsset(asset);
          const isVideo = isVideoAsset(asset);
          if (isImage || isVideo) {
            // Register-and-done. No download, no fileStorage entry.
            // `subscribeFilesBatch` already protects DONE entries from
            // being overwritten (e.g. by user-uploaded media where the
            // uri is the local source path), so this is a no-op for
            // assets that are already in the store as DONE.
            staticAssetRegistrations.push({
              assetId: asset.id,
              uri: null,
              remoteUrl: mapAssetToUrl(asset)!,
              messageId,
              filename: getFixedFilename(asset.filename),
              asset,
            });
            continue;
          }

          // Remaining: audio + documents. These go through the queue.
          // Forwarded messages reuse the same assetId under a new
          // messageId, so we MUST register here even when the original
          // is in flight — duplicate enqueues are deduped downstream by
          // `memoryIsDownloading` + `inflightWaiters`.
          const isAudio = isAudioAsset(asset);
          const isFile = isDocumentAsset(asset);
          let updateStateWhenNotDownloaded: UpdateFileParams | undefined;
          let isValidAudio = null;

          if (isAudio) {
            isValidAudio = asset.isValid;
            if (!isValidAudio) {
              updateStateWhenNotDownloaded = {
                assetId: asset.id,
                messageId,
                state: FileDownloadStatus.BACKEND_NOT_READY,
              };
            }
          }

          if (isFile && !fileAutomaticDownload) {
            updateStateWhenNotDownloaded = {
              assetId: asset.id,
              messageId,
              state: FileDownloadStatus.NOT_AUTOMATIC_DOWNLOAD,
            };
          }

          acc.push({
            assetId: asset.id,
            asset,
            url: mapAssetToUrl(asset)!,
            filename: getFixedFilename(asset.filename),
            messageId,
            channel: messageMetadata.channel,
            // Audios prefetch (subject to `isValidAudio`); documents
            // follow the user's setting.
            shouldDownload:
              (isAudio && !!isValidAudio) || fileAutomaticDownload,
            updateStateWhenNotDownloaded: updateStateWhenNotDownloaded
              ? {...updateStateWhenNotDownloaded}
              : undefined,
          });
        }

        return acc;
      },
      [],
    );

    unstable_batchedUpdates(() => {
      if (staticAssetRegistrations.length > 0) {
        subscribeFilesBatch(staticAssetRegistrations);
        // `subscribeFilesBatch` skips entries already in DONE state
        // (e.g. user-uploaded media still on disk). For those we still
        // want server-side metadata updates — most notably a video's
        // `thumbnail_url` arriving in a later sync — to propagate.
        // `updateAssetsBatch` refreshes the `asset` field regardless
        // of download state.
        updateAssetsBatch(
          staticAssetRegistrations.map(({assetId, messageId, asset}) => ({
            assetId,
            messageId,
            asset,
          })),
        );
      }
    });
    await this.getFilesBatch(preparedData);
    const freeSpace = await ExpoFileSystem.getFreeDiskStorageAsync();
    if (freeSpace <= MIN_FREE_SPACE_BYTES && !this.hasAlreadyCheckFreeSpace) {
      this.hasAlreadyCheckFreeSpace = true;
      this.handleENOSPCError(0, true);
    }
  }

  // public async validateGifs(
  //   gifs: GifToValidate[],
  //   messageId: string,
  //   channelId: string,
  // ): Promise<GifAsset[]> {
  //   const mappedAssets: GifAsset[] = await asyncMap(
  //     gifs,
  //     async ({gifId, asset}) => {
  //       try {
  //         if (!asset.is_available) {
  //           return asset;
  //         }
  //         const isGifValid = await validateGifExistence(gifId);
  //         return {
  //           ...(asset as GifAsset),
  //           is_available: isGifValid,
  //         };
  //       } catch (e) {
  //         return asset;
  //       }
  //     },
  //   );
  //   const assetsToUpdate = mappedAssets.filter(asset => !asset.is_available);
  //   if (assetsToUpdate.length > 0) {
  //     LocalStateUpdater.updateAssets({
  //       assets: assetsToUpdate,
  //       messageId,
  //       channelId,
  //       hidden: false,
  //       enableProcessingAssets: false,
  //     });
  //   }
  //   return assetsToUpdate;
  // }

  public async openFile({
    url,
    remoteUrl,
    assetId,
    messageId,
    filename,
    asset,
    channel,
    willOpen,
    didClose,
  }: {
    url: Nullable<string>;
    remoteUrl: string;
    assetId: number;
    messageId: string;
    filename: string;
    asset: Asset;
    channel: string;
    willOpen?: () => void;
    didClose?: () => void;
  }): Promise<void> {
    const exists = url ? await this.checkIfFileExists(url) : false;
    if (exists && url) {
      if (!isIos) {
        const hasApp = await FileViewer.canOpen(url);
        if (!hasApp) {
          throw new Error(ERROR_MESSAGES.NO_APP_ASSOCIATED);
        }
      }
      willOpen?.();
      const options = didClose
        ? {
            onDismiss: didClose,
          }
        : undefined;
      await FileViewer.open(url, options);
    } else {
      //If file does not exist cause user deleted and we didn't run the update, we have to get it again and update store
      await this.getFilesBatch([
        {
          assetId,
          url: remoteUrl,
          filename: getFixedFilename(filename),
          messageId,
          shouldDownload: true,
          asset,
          channel,
        },
      ]);
    }
  }

  // UI ref-count so we can cancel downloads when no component needs them.
  // Call subscribe() on mount, unsubscribe() on unmount.
  public hasSubscribers(assetId: number): boolean {
    return (this.subscriberCounts.get(assetId) ?? 0) > 0;
  }

  public subscribe(assetId: number): void {
    const next = (this.subscriberCounts.get(assetId) ?? 0) + 1;
    this.subscriberCounts.set(assetId, next);
  }

  public unsubscribe(assetId: number): void {
    const current = this.subscriberCounts.get(assetId) ?? 0;
    if (current <= 1) {
      this.subscriberCounts.delete(assetId);
      this.cancelDownload(assetId);
    } else {
      this.subscriberCounts.set(assetId, current - 1);
    }
  }

  public cancelDownload(assetId: number): void {
    for (let i = downloadQueue.length - 1; i >= 0; i--) {
      if (downloadQueue[i].assetId === assetId) {
        downloadQueue.splice(i, 1);
      }
    }
    const dr = this.currentDownloads[assetId];
    if (dr) {
      dr.cancelAsync().catch(() => {});
      delete this.currentDownloads[assetId];
    }
    this.memoryIsDownloading.delete(assetId);
    this.notifyInflightWaiters(assetId, undefined);
  }

  public clearAsset(assetId: number): void {
    const cachedData = fileStorage.getSync(String(assetId));
    if (cachedData) {
      delete this.currentDownloads[assetId];
      fileStorage.clear(String(assetId));
      ExpoFileSystem.deleteAsync(cachedData.uri, {idempotent: true}).catch(
        () => {},
      );
    }
  }

  public clearCache(): void {
    fileStorage.clearCache();
    ExpoFileSystem.deleteAsync(this.documentDirectory, {
      idempotent: true,
    }).catch(() => {});
    // Also reset the per-instance runtime state so a fresh login/open starts
    // from zero. cancelAllDownloads handles currentDownloads + memoryIsDownloading,
    // but subscriberCounts is ours and needs to be cleared here too.
    this.subscriberCounts.clear();
  }

  public async updateAsset({
    asset,
    assetId,
    withNewAssetId,
  }: {
    assetId: number | string;
    asset: Partial<CacheData>;
    withNewAssetId?: number | string; // replace old id with new id
  }) {
    // Update file handler cache
    const cachedData = fileStorage.getSync(String(assetId));
    if (cachedData) {
      if (withNewAssetId) {
        await fileStorage.set(String(withNewAssetId), {
          ...cachedData,
          ...asset,
        });
        fileStorage.clear(String(assetId));
        return;
      }

      await fileStorage.set(String(assetId), {
        ...cachedData,
        ...asset,
      });
    }
  }
}

export const fileHandler = FileHandler.getInstance();
