import {Image as RNImage} from 'react-native'
import uuid from 'react-native-uuid'
import {
  cacheDirectory,
  copyAsync,
  createDownloadResumable,
  deleteAsync,
  EncodingType,
  getInfoAsync,
  makeDirectoryAsync,
  moveAsync,
  StorageAccessFramework,
  writeAsStringAsync,
} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'

import {formatToFileExt} from '#/lib/media/image-formats'
import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS} from '#/env'
import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
import {getDownloadImageUri, getResizedDimensions} from './util'
import {mimeToExt} from './video/util'

export async function compressIfNeeded(
  img: PickerImage,
  {maxDimension, maxSize}: {maxDimension: number; maxSize: number},
  opts?: {outputMime?: 'image/jpeg' | 'image/webp'; forceEncode?: boolean},
): Promise<PickerImage> {
  const outputMime = opts?.outputMime ?? 'image/jpeg'
  const needsReencode =
    opts?.forceEncode || img.size >= maxSize || img.mime !== outputMime

  if (!needsReencode) {
    return img
  }

  const resizedImage = await doResize(normalizePath(img.path), {
    maxDimension,
    maxSize,
    outputMime,
  })
  const finalImageMovedPath = await moveToPermanentPath(
    resizedImage.path,
    resizedImage.mime === 'image/jpeg' ? '.jpg' : '.webp',
  )
  const finalImg = {
    ...resizedImage,
    path: finalImageMovedPath,
  }
  return finalImg
}

export interface DownloadAndResizeOpts {
  uri: string
  maxDimension: number
  maxSize: number
  timeout: number
}

export async function downloadAndResize(opts: DownloadAndResizeOpts) {
  try {
    new URL(opts.uri)
  } catch (e: any) {
    console.error('Invalid URI', opts.uri, e)
    return
  }

  const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)

  try {
    return await doResize(path, {
      maxDimension: opts.maxDimension,
      maxSize: opts.maxSize,
    })
  } finally {
    void safeDeleteAsync(path)
  }
}

export async function shareImageModal({uri}: {uri: string}) {
  if (!(await Sharing.isAvailableAsync())) {
    // TODO might need to give an error to the user in this case -prf
    return
  }

  const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3)
  const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], {
    format: SaveFormat.JPEG,
    compress: 1.0,
  })
  void safeDeleteAsync(downloadedPath)
  const imagePath = await moveToPermanentPath(jpegUri, '.jpg')
  await Sharing.shareAsync(imagePath, {
    mimeType: 'image/jpeg',
    UTI: 'image/jpeg',
  })
}

const ALBUM_NAME = 'Bluesky'

/**
 * Saves an image to the user's device. Uses the CDN's `download` preset with the
 * chosen format suffix. On native this saves to the media library; on web it
 * triggers a browser download.
 */
export async function saveImageToMediaLibrary({
  uri,
  format = 'jpeg',
}: {
  uri: string
  format?: string
}) {
  const downloadUri = getDownloadImageUri(uri, format)
  const downloadedPath = await downloadImage(
    downloadUri,
    String(uuid.v4()),
    20e3,
  )
  const dotIndex = downloadedPath.lastIndexOf('.')
  const ext =
    dotIndex >= 0
      ? downloadedPath.slice(dotIndex)
      : `.${formatToFileExt(format)}`
  const imagePath = await moveToPermanentPath(downloadedPath, ext)

  // save
  try {
    if (IS_ANDROID) {
      // android triggers an annoying permission prompt if you try and move an image
      // between albums. therefore, we need to either create the album with the image
      // as the starting image, or put it directly into the album
      const album = await MediaLibrary.getAlbumAsync(ALBUM_NAME)
      if (album) {
        // try and migrate if needed
        try {
          if (await MediaLibrary.albumNeedsMigrationAsync(album)) {
            await MediaLibrary.migrateAlbumIfNeededAsync(album)
          }
        } catch (err) {
          logger.info('Attempted and failed to migrate album', {
            safeMessage: err,
          })
        }

        try {
          // if album exists, put the image straight in there
          await MediaLibrary.createAssetAsync(imagePath, album)
        } catch (err) {
          logger.info('Failed to create asset', {safeMessage: err})
          // however, it's possible that we don't have write permission to the album
          // try making a new one!
          try {
            await MediaLibrary.createAlbumAsync(
              ALBUM_NAME,
              undefined,
              undefined,
              imagePath,
            )
          } catch (err2) {
            logger.info('Failed to create asset in a fresh album', {
              safeMessage: err2,
            })
            // ... and if all else fails, just put it in DCIM
            await MediaLibrary.createAssetAsync(imagePath)
          }
        }
      } else {
        // otherwise, create album with asset (albums must always have at least one asset)
        await MediaLibrary.createAlbumAsync(
          ALBUM_NAME,
          undefined,
          undefined,
          imagePath,
        )
      }
    } else {
      await MediaLibrary.saveToLibraryAsync(imagePath)
    }
  } catch (err) {
    logger.error(err instanceof Error ? err : String(err), {
      message: 'Failed to save image to media library',
    })
    throw err
  } finally {
    safeDeleteAsync(imagePath)
  }
}

export async function saveVideoToMediaLibrary({uri}: {uri: string}) {
  // download the file to cache
  const tempPath = `${cacheDirectory ?? ''}/${String(uuid.v4())}.bin`
  const dlResumable = createDownloadResumable(uri, tempPath, {cache: true})
  const dlRes = await dlResumable.downloadAsync().catch(() => null)
  if (!dlRes?.uri) return false

  const contentType =
    dlRes.headers['content-type'] ?? dlRes.headers['Content-Type']
  if (!contentType) {
    void safeDeleteAsync(dlRes.uri)
    return false
  }

  let extension: string
  try {
    extension = mimeToExt(contentType)
  } catch {
    void safeDeleteAsync(dlRes.uri)
    return false
  }

  const videoPath = await moveToPermanentPath(dlRes.uri, '.' + extension)

  // save
  try {
    if (IS_ANDROID) {
      await MediaLibrary.createAlbumAsync(
        ALBUM_NAME,
        undefined,
        undefined,
        videoPath,
      )
    } else {
      await MediaLibrary.saveToLibraryAsync(videoPath)
    }
  } catch (err) {
    logger.error(err instanceof Error ? err : String(err), {
      message: 'Failed to save video to media library',
    })
    throw err
  } finally {
    void safeDeleteAsync(videoPath)
  }
  return true
}

export function getImageDim(path: string): Promise<Dimensions> {
  return new Promise((resolve, reject) => {
    RNImage.getSize(
      path,
      (width, height) => {
        resolve({width, height})
      },
      reject,
    )
  })
}

// internal methods
// =

interface DoResizeOpts {
  maxDimension: number
  maxSize: number
  outputMime?: 'image/jpeg' | 'image/webp'
}

async function doResize(
  localUri: string,
  opts: DoResizeOpts,
): Promise<PickerImage> {
  const outputMime = opts.outputMime ?? 'image/webp'
  const outputFormat =
    outputMime === 'image/jpeg' ? SaveFormat.JPEG : SaveFormat.WEBP
  // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter
  // a "max size", and it would do the "best possible size" calculation for us.
  // Now instead, we have to supply the final dimensions to the manipulation function instead.
  // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
  // does not work for local files...
  const imageRes = await manipulateAsync(localUri, [], {})
  const newDimensions = getResizedDimensions(
    {
      width: imageRes.width,
      height: imageRes.height,
    },
    opts.maxDimension,
  )

  let minQualityPercentage = 0
  let maxQualityPercentage = 101 // exclusive
  let newDataUri
  const intermediateUris = []

  while (maxQualityPercentage - minQualityPercentage > 1) {
    const qualityPercentage = Math.round(
      (maxQualityPercentage + minQualityPercentage) / 2,
    )
    const resizeRes = await manipulateAsync(
      localUri,
      [{resize: newDimensions}],
      {
        format: outputFormat,
        compress: qualityPercentage / 100,
      },
    )

    intermediateUris.push(resizeRes.uri)

    const fileInfo = await getInfoAsync(resizeRes.uri)
    if (!fileInfo.exists) {
      throw new Error(
        'The image manipulation library failed to create a new image.',
      )
    }

    if (fileInfo.size < opts.maxSize) {
      minQualityPercentage = qualityPercentage
      newDataUri = {
        path: normalizePath(resizeRes.uri),
        mime: outputMime,
        size: fileInfo.size,
        width: resizeRes.width,
        height: resizeRes.height,
      }
    } else {
      maxQualityPercentage = qualityPercentage
    }
  }

  for (const intermediateUri of intermediateUris) {
    if (newDataUri?.path !== normalizePath(intermediateUri)) {
      safeDeleteAsync(intermediateUri)
    }
  }

  if (newDataUri) {
    safeDeleteAsync(imageRes.uri)
    return newDataUri
  }

  throw new Error(
    `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
  )
}

async function moveToPermanentPath(path: string, ext: string): Promise<string> {
  /*
  Since this package stores images in a temp directory, we need to move the file to a permanent location.
  Relevant: IOS bug when trying to open a second time:
  https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
  */
  const filename = uuid.v4()

  // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on
  // native so we assert as a string.
  const destinationPath = joinPath(cacheDirectory as string, filename + ext)
  await copyAsync({
    from: normalizePath(path),
    to: normalizePath(destinationPath),
  })
  safeDeleteAsync(path)
  return normalizePath(destinationPath)
}

export async function safeDeleteAsync(path: string) {
  // Normalize is necessary for Android, otherwise it doesn't delete.
  const normalizedPath = normalizePath(path)
  try {
    await deleteAsync(normalizedPath, {idempotent: true})
  } catch (e) {
    console.error('Failed to delete file', e)
  }
}

function joinPath(a: string, b: string) {
  if (a.endsWith('/')) {
    if (b.startsWith('/')) {
      return a.slice(0, -1) + b
    }
    return a + b
  } else if (b.startsWith('/')) {
    return a + b
  }
  return a + '/' + b
}

function normalizePath(str: string, allPlatforms = false): string {
  if (IS_ANDROID || allPlatforms) {
    if (!str.startsWith('file://')) {
      return `file://${str}`
    }
  }
  return str
}

export async function saveBytesToDisk(
  filename: string,
  bytes: Uint8Array,
  type: string,
) {
  // ideally we'd use `bytes.toBase64()`, but that's only baseline newly available
  let binary = ''
  for (const byte of bytes) {
    binary += String.fromCharCode(byte)
  }
  const encoded = btoa(binary)
  return await saveToDevice(filename, encoded, type)
}

export async function saveToDevice(
  filename: string,
  encoded: string,
  type: string,
) {
  try {
    if (IS_IOS) {
      await withTempFile(filename, encoded, async tmpFileUrl => {
        await Sharing.shareAsync(tmpFileUrl, {UTI: type})
      })
      return true
    } else {
      const permissions =
        await StorageAccessFramework.requestDirectoryPermissionsAsync()

      if (!permissions.granted) {
        return false
      }

      const fileUrl = await StorageAccessFramework.createFileAsync(
        permissions.directoryUri,
        filename,
        type,
      )

      await writeAsStringAsync(fileUrl, encoded, {
        encoding: EncodingType.Base64,
      })
      return true
    }
  } catch (e) {
    logger.error('Error occurred while saving file', {message: e})
    return false
  }
}

async function withTempFile<T>(
  filename: string,
  encoded: string,
  cb: (url: string) => T | Promise<T>,
): Promise<T> {
  // cacheDirectory will not ever be null so we assert as a string.
  // Using a directory so that the file name is not a random string
  const tmpDirUri = joinPath(cacheDirectory as string, String(uuid.v4()))
  await makeDirectoryAsync(tmpDirUri, {intermediates: true})

  try {
    const tmpFileUrl = joinPath(tmpDirUri, filename)
    await writeAsStringAsync(tmpFileUrl, encoded, {
      encoding: EncodingType.Base64,
    })

    return await cb(tmpFileUrl)
  } finally {
    safeDeleteAsync(tmpDirUri)
  }
}

async function downloadImage(uri: string, destName: string, timeout: number) {
  // Download to a temp path first, then rename with the correct extension
  // based on the response's mimeType.
  const tempPath = `${cacheDirectory ?? ''}/${destName}.bin`
  const dlResumable = createDownloadResumable(uri, tempPath, {cache: true})
  let timedOut = false
  const to1 = setTimeout(() => {
    timedOut = true
    void dlResumable.cancelAsync()
  }, timeout)

  const dlRes = await dlResumable.downloadAsync()
  clearTimeout(to1)

  if (!dlRes?.uri) {
    if (timedOut) {
      throw new Error('Failed to download image - timed out')
    } else {
      throw new Error('Failed to download image - dlRes is undefined')
    }
  }

  const ext = extFromMime(dlRes.mimeType)
  const finalPath = `${cacheDirectory ?? ''}/${destName}.${ext}`
  await moveAsync({from: dlRes.uri, to: finalPath})

  return normalizePath(finalPath)
}

const MIME_TO_EXT: Record<string, string> = {
  'image/jpeg': 'jpg',
  'image/webp': 'webp',
  'image/png': 'png',
  'image/gif': 'gif',
}

function extFromMime(mimeType?: string | null): string {
  return (mimeType && MIME_TO_EXT[mimeType]) || 'jpg'
}
