import type {
  AttachmentManagerConfig,
  MinimumUploadRequestResult,
  UploadRequestFn,
  UploadRequestOptions,
} from './configuration';
import { isLocalImageAttachment, isUploadedAttachment } from './attachmentIdentity';
import {
  createFileFromBlobs,
  ensureIsLocalAttachment,
  generateFileName,
  getAttachmentTypeFromMimeType,
  isFile,
  isFileList,
  isFileReference,
  isImageFile,
} from './fileUtils';
import {
  AttachmentPostUploadMiddlewareExecutor,
  AttachmentPreUploadMiddlewareExecutor,
} from './middleware/attachmentManager';
import { StateStore } from '../store';
import { generateUUIDv4 } from '../utils';
import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants';
import type {
  AttachmentLoadingState,
  FileLike,
  FileReference,
  LocalAttachment,
  LocalNotImageAttachment,
  LocalUploadAttachment,
  UploadPermissionCheckResult,
} from './types';
import type { ChannelResponse, DraftMessage, LocalMessage } from '../types';
import type { MessageComposer } from './messageComposer';
import { mergeWithDiff } from '../utils/mergeWith';

export type FileUploadFilter = (file: Partial<LocalUploadAttachment>) => boolean;

export type AttachmentManagerState = {
  attachments: LocalAttachment[];
};

export type AttachmentManagerSnapshot = AttachmentManagerState;

export type AttachmentManagerOptions = {
  composer: MessageComposer;
  message?: DraftMessage | LocalMessage;
};

const initState = ({
  message,
}: {
  message?: DraftMessage | LocalMessage;
}): AttachmentManagerState => ({
  attachments: (message?.attachments ?? [])
    ?.filter(({ og_scrape_url }) => !og_scrape_url)
    .map((att) => {
      const localMetadata = isUploadedAttachment(att)
        ? { id: generateUUIDv4(), uploadState: 'finished' }
        : { id: generateUUIDv4() };
      return {
        ...att,
        localMetadata,
      } as LocalAttachment;
    }),
});

export class AttachmentManager {
  readonly state: StateStore<AttachmentManagerState>;
  readonly composer: MessageComposer;
  readonly preUploadMiddlewareExecutor: AttachmentPreUploadMiddlewareExecutor;
  readonly postUploadMiddlewareExecutor: AttachmentPostUploadMiddlewareExecutor;
  private attachmentsByIdGetterCache: {
    attachmentsById: Record<string, LocalAttachment>;
    attachments: LocalAttachment[];
  };

  constructor({ composer, message }: AttachmentManagerOptions) {
    this.composer = composer;
    this.state = new StateStore<AttachmentManagerState>(initState({ message }));
    this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] };

    this.preUploadMiddlewareExecutor = new AttachmentPreUploadMiddlewareExecutor({
      composer,
    });
    this.postUploadMiddlewareExecutor = new AttachmentPostUploadMiddlewareExecutor({
      composer,
    });
  }

  get attachmentsById() {
    const { attachments } = this.state.getLatestValue();

    if (attachments !== this.attachmentsByIdGetterCache.attachments) {
      this.attachmentsByIdGetterCache.attachments = attachments;
      this.attachmentsByIdGetterCache.attachmentsById = attachments.reduce<
        Record<string, LocalAttachment>
      >((newAttachmentsById, attachment) => {
        // should never happen but does not hurt to check
        if (!attachment.localMetadata.id) return newAttachmentsById;

        newAttachmentsById[attachment.localMetadata.id] ??= attachment;

        return newAttachmentsById;
      }, {});
    }

    return this.attachmentsByIdGetterCache.attachmentsById;
  }

  get client() {
    return this.composer.client;
  }

  get channel() {
    return this.composer.channel;
  }

  get config() {
    return this.composer.config.attachments;
  }

  get acceptedFiles() {
    return this.config.acceptedFiles;
  }

  set acceptedFiles(acceptedFiles: AttachmentManagerConfig['acceptedFiles']) {
    this.composer.updateConfig({ attachments: { acceptedFiles } });
  }

  /*
  @deprecated attachments can be filtered using injecting pre-upload middleware
   */
  get fileUploadFilter() {
    return this.config.fileUploadFilter;
  }

  /*
  @deprecated attachments can be filtered using injecting pre-upload middleware
   */
  set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) {
    this.composer.updateConfig({ attachments: { fileUploadFilter } });
  }

  get maxNumberOfFilesPerMessage() {
    return this.config.maxNumberOfFilesPerMessage;
  }

  set maxNumberOfFilesPerMessage(
    maxNumberOfFilesPerMessage: AttachmentManagerConfig['maxNumberOfFilesPerMessage'],
  ) {
    if (maxNumberOfFilesPerMessage === this.maxNumberOfFilesPerMessage) return;
    this.composer.updateConfig({ attachments: { maxNumberOfFilesPerMessage } });
  }

  setCustomUploadFn = (doUploadRequest: UploadRequestFn) => {
    this.composer.updateConfig({ attachments: { doUploadRequest } });
  };

  get attachments() {
    return this.state.getLatestValue().attachments;
  }

  get hasUploadPermission() {
    return !!(
      this.channel.data?.own_capabilities as ChannelResponse['own_capabilities']
    )?.includes('upload-file');
  }

  get isUploadEnabled() {
    return this.hasUploadPermission && this.availableUploadSlots > 0;
  }

  get successfulUploads() {
    return this.getUploadsByState('finished');
  }

  get successfulUploadsCount() {
    return this.successfulUploads.length;
  }

  get uploadsInProgressCount() {
    return this.getUploadsByState('uploading').length;
  }

  get failedUploadsCount() {
    return this.getUploadsByState('failed').length;
  }

  get blockedUploadsCount() {
    return this.getUploadsByState('blocked').length;
  }

  get pendingUploadsCount() {
    return this.getUploadsByState('pending').length;
  }

  get availableUploadSlots() {
    return (
      this.config.maxNumberOfFilesPerMessage -
      this.successfulUploadsCount -
      this.uploadsInProgressCount
    );
  }

  getUploadsByState(state: AttachmentLoadingState) {
    return Object.values(this.attachments).filter(
      ({ localMetadata }) => localMetadata.uploadState === state,
    );
  }

  cancelAttachmentUploads = (attachments: LocalAttachment[] = this.attachments) => {
    for (const { localMetadata } of attachments) {
      this.client.uploadManager.deleteUploadRecord(localMetadata.id);
    }
  };

  private normalizeSnapshotAttachment = (attachment: LocalAttachment) => {
    if (attachment.localMetadata.uploadState !== 'uploading') return attachment;

    this.client.uploadManager.deleteUploadRecord(attachment.localMetadata.id);

    return {
      ...attachment,
      localMetadata: {
        ...attachment.localMetadata,
        uploadProgress: undefined,
        uploadState: 'failed',
      },
    } as LocalAttachment;
  };

  initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => {
    this.state.next(initState({ message }));
  };

  getSnapshot = (): AttachmentManagerSnapshot => {
    const state = this.state.getLatestValue();
    let hasUpdates = false;
    const attachments = state.attachments.map(this.normalizeSnapshotAttachment);

    for (let i = 0; i < attachments.length; i++) {
      if (attachments[i] !== state.attachments[i]) {
        hasUpdates = true;
        break;
      }
    }

    return hasUpdates ? { ...state, attachments } : state;
  };

  restoreSnapshot = (snapshot: AttachmentManagerSnapshot) => {
    this.cancelAttachmentUploads(this.attachments);
    this.state.next(snapshot);
  };

  setAttachments = (attachments: LocalAttachment[]) => {
    this.state.partialNext({ attachments });
  };

  clearAttachments = () => {
    if (!this.attachments.length) return;
    this.removeAttachments(
      this.attachments.map((attachment) => attachment.localMetadata.id),
    );
  };

  getAttachmentIndex = (localId: string) => {
    const attachmentsById = this.attachmentsById;

    return this.attachments.indexOf(attachmentsById[localId]);
  };

  private prepareAttachmentUpdate = (attachmentToUpdate: LocalAttachment) => {
    const stateAttachments = this.attachments;
    const attachments = [...this.attachments];
    const attachmentIndex = this.getAttachmentIndex(attachmentToUpdate.localMetadata.id);
    if (attachmentIndex === -1) return null;
    // do not re-organize newAttachments array otherwise indexing would no longer work
    // replace in place only with the attachments with the same id's
    const merged = mergeWithDiff<LocalAttachment>(
      stateAttachments[attachmentIndex],
      attachmentToUpdate,
    );
    const updatesOnMerge = merged.diff && Object.keys(merged.diff.children).length;
    if (updatesOnMerge) {
      const localAttachment = ensureIsLocalAttachment(merged.result);
      if (localAttachment) {
        attachments.splice(attachmentIndex, 1, localAttachment);
        return attachments;
      }
    }
    return stateAttachments;
  };

  updateAttachment = (attachmentToUpdate: LocalAttachment) => {
    const updatedAttachments = this.prepareAttachmentUpdate(attachmentToUpdate);
    if (updatedAttachments && updatedAttachments !== this.attachments) {
      this.state.partialNext({ attachments: updatedAttachments });
    }
  };

  upsertAttachments = (attachmentsToUpsert: LocalAttachment[]) => {
    if (!attachmentsToUpsert.length) return;
    let attachments = [...this.attachments];
    let hasUpdates = false;
    attachmentsToUpsert.forEach((attachment) => {
      const updatedAttachments = this.prepareAttachmentUpdate(attachment);
      if (updatedAttachments === null) {
        const localAttachment = ensureIsLocalAttachment(attachment);
        if (localAttachment) {
          attachments.push(localAttachment);
          hasUpdates = true;
        }
      } else if (updatedAttachments !== this.attachments) {
        attachments = updatedAttachments;
        hasUpdates = true;
      }
      // else: id exists and merge was a no-op (`prepareAttachmentUpdate` returns current state)
    });
    if (hasUpdates) {
      this.state.partialNext({ attachments });
    }
  };

  removeAttachments = (localAttachmentIds: string[]) => {
    if (!localAttachmentIds.length) return;

    this.state.partialNext({
      attachments: this.attachments.filter(
        (attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id),
      ),
    });

    for (const id of localAttachmentIds) {
      this.client.uploadManager.deleteUploadRecord(id);
    }
  };

  getUploadConfigCheck = async (
    fileLike: FileReference | FileLike,
  ): Promise<UploadPermissionCheckResult> => {
    const client = this.channel.getClient();
    let appSettings;
    if (!client.appSettingsPromise) {
      appSettings = await client.getAppSettings();
    } else {
      appSettings = await client.appSettingsPromise;
    }
    const uploadConfig = isImageFile(fileLike)
      ? appSettings?.app?.image_upload_config
      : appSettings?.app?.file_upload_config;
    if (!uploadConfig) return { uploadBlocked: false };

    const {
      allowed_file_extensions,
      allowed_mime_types,
      blocked_file_extensions,
      blocked_mime_types,
      size_limit,
    } = uploadConfig;

    const sizeLimit = size_limit || DEFAULT_UPLOAD_SIZE_LIMIT_BYTES;
    const mimeType = fileLike.type;

    if (isFile(fileLike) || isFileReference(fileLike)) {
      if (
        allowed_file_extensions?.length &&
        !allowed_file_extensions.some((ext) =>
          fileLike.name.toLowerCase().endsWith(ext.toLowerCase()),
        )
      ) {
        return { uploadBlocked: true, reason: 'allowed_file_extensions' };
      }

      if (
        blocked_file_extensions?.length &&
        blocked_file_extensions.some((ext) =>
          fileLike.name.toLowerCase().endsWith(ext.toLowerCase()),
        )
      ) {
        return { uploadBlocked: true, reason: 'blocked_file_extensions' };
      }
    }

    if (
      allowed_mime_types?.length &&
      !allowed_mime_types.some((type) => type.toLowerCase() === mimeType?.toLowerCase())
    ) {
      return { uploadBlocked: true, reason: 'allowed_mime_types' };
    }

    if (
      blocked_mime_types?.length &&
      blocked_mime_types.some((type) => type.toLowerCase() === mimeType?.toLowerCase())
    ) {
      return { uploadBlocked: true, reason: 'blocked_mime_types' };
    }

    if (fileLike.size && fileLike.size > sizeLimit) {
      return { uploadBlocked: true, reason: 'size_limit' };
    }

    return { uploadBlocked: false };
  };

  static toLocalUploadAttachment = (
    fileLike: FileReference | FileLike,
  ): LocalUploadAttachment => {
    const file =
      isFileReference(fileLike) || isFile(fileLike)
        ? fileLike
        : createFileFromBlobs({
            blobsArray: [fileLike],
            fileName: generateFileName(fileLike.type),
            mimeType: fileLike.type,
          });

    const localAttachment: LocalUploadAttachment = {
      file_size: file.size,
      mime_type: file.type,
      localMetadata: {
        file,
        id: generateUUIDv4(),
        uploadState: 'pending',
      },
      type: getAttachmentTypeFromMimeType(file.type),
    };

    localAttachment[isImageFile(file) ? 'fallback' : 'title'] = file.name;

    localAttachment.localMetadata.previewUri = isFileReference(fileLike)
      ? fileLike.uri
      : URL.createObjectURL?.(fileLike);

    if (
      isFileReference(fileLike) &&
      fileLike.height &&
      fileLike.width &&
      isImageFile(file)
    ) {
      localAttachment.original_height = fileLike.height;
      localAttachment.original_width = fileLike.width;
    }

    if (isFileReference(fileLike) && fileLike.thumb_url) {
      localAttachment.thumb_url = fileLike.thumb_url;
    }

    if (isFileReference(fileLike) && fileLike.duration) {
      localAttachment.duration = fileLike.duration;
    }

    return localAttachment;
  };

  // @deprecated use AttachmentManager.toLocalUploadAttachment(file)
  fileToLocalUploadAttachment = async (
    fileLike: FileReference | FileLike,
  ): Promise<LocalUploadAttachment> => {
    const localAttachment = AttachmentManager.toLocalUploadAttachment(fileLike);
    const uploadPermissionCheck = await this.getUploadConfigCheck(
      localAttachment.localMetadata.file,
    );
    localAttachment.localMetadata.uploadPermissionCheck = uploadPermissionCheck;
    localAttachment.localMetadata.uploadState = uploadPermissionCheck.uploadBlocked
      ? 'blocked'
      : 'pending';

    return localAttachment;
  };

  private ensureLocalUploadAttachment = async (
    attachment: Partial<LocalUploadAttachment>,
  ) => {
    if (!attachment.localMetadata?.file) {
      this.client.notifications.addError({
        message: 'File is required for upload attachment',
        origin: { emitter: 'AttachmentManager', context: { attachment } },
        options: { type: 'validation:attachment:file:missing' },
      });
      return;
    }

    if (!attachment.localMetadata.id) {
      this.client.notifications.addError({
        message: 'Local upload attachment missing local id',
        origin: { emitter: 'AttachmentManager', context: { attachment } },
        options: { type: 'validation:attachment:id:missing' },
      });
      return;
    }

    if (!this.fileUploadFilter(attachment)) return;

    const newAttachment = await this.fileToLocalUploadAttachment(
      attachment.localMetadata.file,
    );
    if (attachment.localMetadata.id) {
      newAttachment.localMetadata.id = attachment.localMetadata.id;
    }
    return newAttachment;
  };

  /**
   * Method to perform the default upload behavior without checking for custom upload functions
   * to prevent recursive calls
   */
  doDefaultUploadRequest = async (
    fileLike: FileReference | FileLike,
    options?: UploadRequestOptions,
  ) => {
    const progressHandler = options?.onProgress
      ? (progressEvent: {
          loaded: number;
          total?: number;
          lengthComputable?: boolean;
        }) => {
          const percent =
            progressEvent.lengthComputable && progressEvent.total
              ? Math.round((progressEvent.loaded * 100) / progressEvent.total)
              : undefined;
          options.onProgress?.(percent);
        }
      : undefined;

    const axiosUploadConfig =
      progressHandler || options?.abortSignal
        ? {
            ...(progressHandler ? { onUploadProgress: progressHandler } : {}),
            ...(options?.abortSignal ? { signal: options.abortSignal } : {}),
          }
        : undefined;

    if (isFileReference(fileLike)) {
      return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](
        fileLike.uri,
        fileLike.name,
        fileLike.type,
        undefined,
        axiosUploadConfig,
      );
    }

    const file = isFile(fileLike)
      ? fileLike
      : createFileFromBlobs({
          blobsArray: [fileLike],
          fileName: generateFileName(fileLike.type),
          mimeType: fileLike.type,
        });

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { duration, ...result } = await this.channel[
      isImageFile(fileLike) ? 'sendImage' : 'sendFile'
    ](file, undefined, undefined, undefined, axiosUploadConfig);
    return result;
  };

  /**
   * todo: docs how to customize the image and file upload by overriding do
   */

  doUploadRequest = async (
    fileLike: FileReference | FileLike,
    options?: UploadRequestOptions,
  ) => {
    const customUploadFn = this.config.doUploadRequest;
    if (customUploadFn) {
      return await customUploadFn(fileLike, options);
    }

    return this.doDefaultUploadRequest(fileLike, options);
  };

  // @deprecated use attachmentManager.uploadFile(file)
  uploadAttachment = async (attachment: LocalUploadAttachment) => {
    if (!this.isUploadEnabled) return;

    const localAttachment = await this.ensureLocalUploadAttachment(attachment);

    if (typeof localAttachment === 'undefined') return;

    if (localAttachment.localMetadata.uploadState === 'blocked') {
      this.upsertAttachments([localAttachment]);
      this.client.notifications.addError({
        message: `The attachment upload was blocked`,
        origin: {
          emitter: 'AttachmentManager',
          context: { attachment, blockedAttachment: localAttachment },
        },
        options: {
          type: 'validation:attachment:upload:blocked',
          metadata: {
            reason: localAttachment.localMetadata.uploadPermissionCheck?.reason,
          },
        },
      });
      return localAttachment;
    }

    let response: MinimumUploadRequestResult;
    try {
      response = await this.upload(attachment);
    } catch (error) {
      const reason = error instanceof Error ? error.message : 'unknown error';
      const failedAttachment: LocalUploadAttachment = {
        ...attachment,
        localMetadata: {
          ...attachment.localMetadata,
          uploadState: 'failed',
          uploadProgress: undefined,
        },
      };

      this.client.notifications.addError({
        message: 'Error uploading attachment',
        origin: {
          emitter: 'AttachmentManager',
          context: { attachment, failedAttachment },
        },
        options: {
          type: 'api:attachment:upload:failed',
          metadata: { reason },
          originalError: error instanceof Error ? error : undefined,
        },
      });

      this.updateAttachment(failedAttachment);
      return failedAttachment;
    }

    if (!response) {
      // Copied this from useImageUpload / useFileUpload.

      // If doUploadRequest returns any falsy value, then don't create the upload preview.
      // This is for the case if someone wants to handle failure on app level.
      this.removeAttachments([attachment.localMetadata.id]);
      return;
    }

    const uploadedAttachment: LocalUploadAttachment = {
      ...attachment,
      localMetadata: {
        ...attachment.localMetadata,
        uploadState: 'finished',
        uploadProgress: undefined,
      },
    };

    const previewUri = uploadedAttachment.localMetadata.previewUri;
    if (previewUri) {
      if (previewUri.startsWith('blob:')) URL.revokeObjectURL(previewUri);
      delete uploadedAttachment.localMetadata.previewUri;
    }

    if (isLocalImageAttachment(uploadedAttachment)) {
      uploadedAttachment.image_url = response.file;
    } else {
      (uploadedAttachment as LocalNotImageAttachment).asset_url = response.file;
    }
    if (response.thumb_url) {
      (uploadedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url;
    }

    this.updateAttachment(uploadedAttachment);

    return uploadedAttachment;
  };

  uploadFile = async (file: FileReference | FileLike) => {
    const preUpload = await this.preUploadMiddlewareExecutor.execute({
      eventName: 'prepare',
      initialValue: {
        attachment: AttachmentManager.toLocalUploadAttachment(file),
      },
      mode: 'concurrent',
    });

    let attachment: LocalUploadAttachment = preUpload.state.attachment;

    if (preUpload.status === 'discard') return attachment;
    // todo: remove with the next major release as filtering can be done in middleware
    // should we return the attachment object?
    if (!this.fileUploadFilter(attachment)) return attachment;

    if (attachment.localMetadata.uploadState === 'blocked') {
      this.upsertAttachments([attachment]);
      return preUpload.state.attachment;
    }

    let response: MinimumUploadRequestResult | undefined;
    let error: Error | undefined;
    try {
      response = await this.upload(attachment);
    } catch (err) {
      error = err instanceof Error ? err : undefined;
    }

    const postUpload = await this.postUploadMiddlewareExecutor.execute({
      eventName: 'postProcess',
      initialValue: {
        attachment: {
          ...attachment,
          localMetadata: {
            ...attachment.localMetadata,
            uploadState: error ? 'failed' : 'finished',
            uploadProgress: undefined,
          },
        },
        error,
        response,
      },
      mode: 'concurrent',
    });
    attachment = postUpload.state.attachment;

    if (postUpload.status === 'discard') {
      this.removeAttachments([attachment.localMetadata.id]);
      return attachment;
    }

    this.updateAttachment(attachment);
    return attachment;
  };

  uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => {
    if (!this.isUploadEnabled) return;
    const iterableFiles: FileReference[] | FileLike[] = isFileList(files)
      ? Array.from(files)
      : files;

    return await Promise.all(
      iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile),
    );
  };

  private upload(attachment: LocalUploadAttachment) {
    const localId = attachment.localMetadata.id;

    this.upsertAttachments([
      {
        ...attachment,
        localMetadata: {
          ...attachment.localMetadata,
          uploadState: 'uploading',
          uploadProgress: this.config.trackUploadProgress ? 0 : undefined,
        },
      },
    ]);

    const unsubscribe = this.client.uploadManager.state.subscribeWithSelector(
      (s) => ({ upload: s.uploads[localId] }),
      ({ upload: nextUpload }) => {
        if (!nextUpload) return;
        this.updateAttachment({
          ...attachment,
          localMetadata: {
            ...attachment.localMetadata,
            uploadState: 'uploading',
            uploadProgress: nextUpload.uploadProgress,
          },
        });
      },
    );

    return this.client.uploadManager
      .upload({
        id: localId,
        channelCid: this.channel.cid,
        file: attachment.localMetadata.file,
      })
      .finally(() => {
        unsubscribe();
      });
  }
}
