import type { Uppy, UppyEventMap } from '../index.js'
import type { Meta } from '../utils/index.js'
import {
  type CompanionPluginOptions,
  type RequestClientOptions,
  tokenStorage,
} from './index.js'

export type GooglePickerType = 'drive' | 'photos'

// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems
// Note that the google api doc is not correct, hence some things are optional here but not in their docs
export interface MediaItemBase {
  id: string
  createTime: string
}

interface MediaFileMetadataBase {
  width: number
  height: number
}

interface MediaFileBase {
  baseUrl: string
  mimeType: string
  filename: string
}

export interface VideoMediaItem extends MediaItemBase {
  type: 'VIDEO'
  mediaFile: MediaFileBase & {
    mediaFileMetadata: MediaFileMetadataBase & {
      videoMetadata: {
        cameraMake?: string
        cameraModel?: string
        fps?: number
        processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED'
      }
    }
  }
}

export interface PhotoMediaItem extends MediaItemBase {
  type: 'PHOTO'
  mediaFile: MediaFileBase & {
    mediaFileMetadata: MediaFileMetadataBase & {
      photoMetadata?: {
        cameraMake?: string
        cameraModel?: string
        focalLength?: number
        apertureFNumber?: number
        isoEquivalent?: number
        exposureTime?: string
      }
    }
  }
}

export interface UnspecifiedMediaItem extends MediaItemBase {
  type: 'TYPE_UNSPECIFIED'
  mediaFile: MediaFileBase & {
    mediaFileMetadata: MediaFileMetadataBase
  }
}

export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem

export type MediaType = MediaItem['type']

// https://developers.google.com/photos/picker/reference/rest/v1/sessions
export interface PickingSession {
  id: string
  pickerUri: string
  pollingConfig: {
    pollInterval: string
    timeoutIn: string
  }
  expireTime: string
  mediaItemsSet: boolean
}

export interface PickedItemBase {
  id: string
  mimeType: string
  name: string
}

export interface PickedDriveItem extends PickedItemBase {
  platform: 'drive'
}

export interface PickedPhotosItem extends PickedItemBase {
  platform: 'photos'
  url: string
  metadata?: Record<string, string | number> // I think string and number is OK in Companion
}

export type PickedItem = PickedPhotosItem | PickedDriveItem

type PickerType = 'drive' | 'photos'

const getAuthHeader = (token: string) => ({
  authorization: `Bearer ${token}`,
})

const injectedScripts = new Set<string>()
let driveApiLoaded = false

// https://stackoverflow.com/a/39008859/6519037
async function injectScript(src: string) {
  if (injectedScripts.has(src)) return

  await new Promise<void>((resolve, reject) => {
    const script = document.createElement('script')
    script.src = src
    script.addEventListener('load', () => resolve())
    script.addEventListener('error', (e) => reject(e.error))
    document.head.appendChild(script)
  })
  injectedScripts.add(src)
}

async function ensureScriptsInjected(pickerType: PickerType): Promise<void> {
  await Promise.all([
    injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services
    (async () => {
      await injectScript('https://apis.google.com/js/api.js')

      if (pickerType === 'drive' && !driveApiLoaded) {
        await new Promise<void>((resolve) =>
          gapi.load('client:picker', () => resolve()),
        )
        await gapi.client.load(
          'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
        )
        driveApiLoaded = true
      }
    })(),
  ])
}

async function isTokenValid(
  accessToken: string,
  signal: AbortSignal | undefined,
) {
  const response = await fetch(
    `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
    { signal },
  )
  if (response.ok) {
    return true
  }
  // console.warn('Token is invalid or expired:', response.status, await response.text());
  // Token is invalid or expired
  return false
}

async function authorize({
  pickerType,
  clientId,
  accessToken,
}: {
  pickerType: PickerType
  clientId: string
  accessToken?: string | null | undefined
}): Promise<string> {
  const response = await new Promise<google.accounts.oauth2.TokenResponse>(
    (resolve, reject) => {
      const scopes =
        pickerType === 'drive'
          ? ['https://www.googleapis.com/auth/drive.file']
          : ['https://www.googleapis.com/auth/photospicker.mediaitems.readonly']

      const tokenClient = google.accounts.oauth2.initTokenClient({
        client_id: clientId,
        // Authorization scopes required by the API; multiple scopes can be included, separated by spaces.
        scope: scopes.join(' '),
        callback: resolve,
        error_callback: reject,
      })

      if (accessToken === null) {
        // Prompt the user to select a Google Account and ask for consent to share their data
        // when establishing a new session.
        tokenClient.requestAccessToken({ prompt: 'consent' })
      } else {
        // Skip display of account chooser and consent dialog for an existing session.
        tokenClient.requestAccessToken({ prompt: '' })
      }
    },
  )

  if (response.error) {
    throw new Error(`OAuth2 error: ${response.error}`)
  }
  return response.access_token
}

async function doLogout(accessToken: string): Promise<void> {
  await new Promise<void>((resolve) =>
    google.accounts.oauth2.revoke(accessToken, resolve),
  )
}

export class InvalidTokenError extends Error {
  constructor() {
    super('Invalid or expired token')
    this.name = 'InvalidTokenError'
  }
}

async function handleDocObjectRecursively({
  doc,
  token,
  signal,
}: {
  doc: {
    id: string
    name: string
    mimeType: string
    shortcutDetails?: { targetMimeType: string }
  }
  token: string
  signal?: AbortSignal
}): Promise<PickedDriveItem[]> {
  if (doc.mimeType === 'application/vnd.google-apps.shortcut') {
    if (
      doc.shortcutDetails?.targetMimeType ===
      'application/vnd.google-apps.folder'
    ) {
      // If we were to recurse into shortcuts to folders, it could get a bit crazy. We could end up picking things outside of the user's intended scope as well as infinite loops
      // If we were to just pass it through as-is, Companion would not be able to download it, so we just ignore it entirely
      return []
    }
    // for other shortcut types, we just treat them as normal files and pass them to Companion to resolve
    return [
      {
        platform: 'drive',
        id: doc.id,
        name: doc.name,
        mimeType: doc.mimeType,
      },
    ]
  }
  if (doc.mimeType !== 'application/vnd.google-apps.folder') {
    return [
      {
        platform: 'drive',
        id: doc.id,
        name: doc.name,
        mimeType: doc.mimeType,
      },
    ]
  }

  const headers = getAuthHeader(token)
  const items: PickedDriveItem[] = []
  let pageToken: string | undefined

  do {
    const params = new URLSearchParams({
      q: `'${doc.id.replace(/'/g, "\\'")}' in parents and trashed = false`,
      fields:
        'nextPageToken, files(id, name, mimeType, shortcutDetails(targetMimeType))',
      includeItemsFromAllDrives: 'true',
      supportsAllDrives: 'true',
      pageSize: '1000',
      ...(pageToken && { pageToken }),
    })
    const res = await fetch(
      `https://www.googleapis.com/drive/v3/files?${params.toString()}`,
      { headers, signal },
    )

    if (!res.ok) {
      throw new Error(
        `Failed to list folder contents for '${doc.name}' (${doc.id}): ${res.status} ${res.statusText}`,
      )
    }
    const json: { nextPageToken?: string; files: PickedItemBase[] } =
      await res.json()
    pageToken = json.nextPageToken

    for (const file of json.files) {
      items.push(
        ...(await handleDocObjectRecursively({ doc: file, token, signal })),
      )
    }
  } while (pageToken)

  return items
}

async function showDrivePicker({
  token,
  apiKey,
  appId,
  onFilesPicked,
  signal,
  onLoadingChange,
  onError,
}: {
  token: string
  apiKey: string
  appId: string
  onFilesPicked: (files: PickedItem[], accessToken: string) => void
  signal: AbortSignal | undefined
  onLoadingChange: (loading: boolean) => void
  onError: (err: unknown) => void
}): Promise<void> {
  // google drive picker will crash hard if given an invalid token, so we need to check it first
  // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265
  if (!(await isTokenValid(token, signal))) {
    throw new InvalidTokenError()
  }

  const onPicked = async (picked: google.picker.ResponseObject) => {
    if (picked.action !== google.picker.Action.PICKED) return

    try {
      onLoadingChange(true)

      const results: PickedDriveItem[] = []
      for (const doc of picked.docs) {
        results.push(
          ...(await handleDocObjectRecursively({ doc, token, signal })),
        )
      }
      onFilesPicked(results, token)
    } catch (err) {
      onError(err)
    } finally {
      onLoadingChange(false)
    }
  }

  const picker = new google.picker.PickerBuilder()
    .enableFeature(google.picker.Feature.NAV_HIDDEN)
    .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
    .setDeveloperKey(apiKey)
    .setAppId(appId)
    .setOAuthToken(token)
    .addView(
      new google.picker.DocsView(google.picker.ViewId.DOCS)
        .setIncludeFolders(true)
        // Note: setEnableDrives doesn't seem to work
        // .setEnableDrives(true)
        .setSelectFolderEnabled(true)
        .setMode(google.picker.DocsViewMode.LIST),
    )
    // NOTE: photos is broken and results in an error being returned from Google
    // I think it's the old Picasa photos
    // .addView(google.picker.ViewId.PHOTOS)
    .setCallback(onPicked)
    .build()

  picker.setVisible(true)
  signal?.addEventListener('abort', () => picker.dispose())
}

async function showPhotosPicker({
  token,
  pickingSession,
  onPickingSessionChange,
  signal,
}: {
  token: string
  pickingSession: PickingSession | undefined
  onPickingSessionChange: (ps: PickingSession) => void
  signal: AbortSignal | undefined
}): Promise<void> {
  // https://developers.google.com/photos/picker/guides/get-started-picker
  const headers = getAuthHeader(token)

  let newPickingSession = pickingSession
  if (newPickingSession == null) {
    const createSessionResponse = await fetch(
      'https://photospicker.googleapis.com/v1/sessions',
      { method: 'post', headers, signal },
    )

    if (createSessionResponse.status === 401) {
      const resp = await createSessionResponse.json()
      if (resp.error?.status === 'UNAUTHENTICATED') {
        throw new InvalidTokenError()
      }
    }

    if (!createSessionResponse.ok) {
      throw new Error('Failed to create a session')
    }
    newPickingSession = (await createSessionResponse.json()) as PickingSession

    onPickingSessionChange(newPickingSession)
  }

  const w = window.open(newPickingSession.pickerUri)
  signal?.addEventListener('abort', () => w?.close())
}

async function resolvePickedPhotos({
  accessToken,
  pickingSession,
  signal,
}: {
  accessToken: string
  pickingSession: PickingSession
  signal: AbortSignal
}) {
  const headers = getAuthHeader(accessToken)

  let pageToken: string | undefined
  let mediaItems: MediaItem[] = []
  do {
    const pageSize = 100
    const response = await fetch(
      `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: pickingSession.id, pageSize: String(pageSize), ...(pageToken && { pageToken }) }).toString()}`,
      { headers, signal },
    )
    if (!response.ok) throw new Error('Failed to get a media items')
    const {
      mediaItems: batchMediaItems,
      nextPageToken,
    }: { mediaItems: MediaItem[]; nextPageToken?: string } =
      await response.json()
    pageToken = nextPageToken
    mediaItems.push(...batchMediaItems)
  } while (pageToken)

  // Filter out items that aren't fully processed or ready
  mediaItems = mediaItems.flatMap((i) =>
    i.type === 'PHOTO' ||
    (i.type === 'VIDEO' &&
      i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === 'READY')
      ? [i]
      : [],
  )

  // Transform media items into picked items with appropriate metadata
  return mediaItems.map((mediaItem) => {
    const {
      id,
      type,
      mediaFile: { mimeType, filename, baseUrl },
    } = mediaItem

    return {
      platform: 'photos' as const,
      id,
      mimeType,
      // we want the original resolution, so we don't append any parameter to the baseUrl
      // https://developers.google.com/photos/library/guides/access-media-items#base-urls
      url: type === 'VIDEO' ? `${baseUrl}=dv` : `${baseUrl}=d`, // dv to download video, d to get original image (non cropped)
      name: filename,
      metadata: {
        // Note that metadata keys `filename` and `type` have special meanings in Companion
        // and should not be overridden
        googlePhotosFileType: mediaItem.type,
        createTime: mediaItem.createTime,

        width: mediaItem.mediaFile.mediaFileMetadata.width,
        height: mediaItem.mediaFile.mediaFileMetadata.height,

        ...(mediaItem.type === 'PHOTO' && {
          cameraMake:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.cameraMake,
          cameraModel:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.cameraModel,
          focalLength:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.focalLength,
          apertureFNumber:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata
              ?.apertureFNumber,
          isoEquivalent:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.isoEquivalent,
          exposureTime:
            mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.exposureTime,
        }),

        ...(mediaItem.type === 'VIDEO' && {
          cameraMake:
            mediaItem.mediaFile.mediaFileMetadata.videoMetadata.cameraMake,
          cameraModel:
            mediaItem.mediaFile.mediaFileMetadata.videoMetadata.cameraModel,
          fps: mediaItem.mediaFile.mediaFileMetadata.videoMetadata.fps,
          processingStatus:
            mediaItem.mediaFile.mediaFileMetadata.videoMetadata
              .processingStatus,
        }),
      },
    }
  })
}

async function pollPickingSession({
  getPickingSession,
  getAccessToken,
  onPickingSessionClear,
  signal,
  onFilesPicked,
  onError,
}: {
  getPickingSession: () => PickingSession | undefined
  getAccessToken: () => string | null | undefined
  onPickingSessionClear: () => void
  signal: AbortSignal
  onFilesPicked: (files: PickedItem[], accessToken: string) => void
  onError: (err: unknown) => void
}): Promise<void> {
  // if we have an active session, poll it until it either times out, or the user selects some photos.
  // Note that the user can also just close the page, but we get no indication of that from Google when polling,
  // so we just have to continue polling in the background, so we can react to it
  // in case the user opens the photo selector again. Hence the infinite for loop
  for (let interval = 1; ; ) {
    try {
      signal.throwIfAborted()

      let pickingSession = getPickingSession()
      if (pickingSession != null) {
        interval = parseFloat(pickingSession.pollingConfig.pollInterval)
      } else {
        interval = 1
      }

      await new Promise<void>((resolve, reject) => {
        setTimeout(() => {
          signal.removeEventListener('abort', reject)
          resolve()
        }, interval * 1000)
        signal.addEventListener('abort', reject, { once: true })
      })

      const accessToken = getAccessToken()
      pickingSession = getPickingSession()

      if (pickingSession != null && accessToken != null) {
        const headers = getAuthHeader(accessToken)

        // https://developers.google.com/photos/picker/reference/rest/v1/sessions
        const response = await fetch(
          `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`,
          { headers, signal },
        )
        if (!response.ok) throw new Error('Failed to get session')
        const json: PickingSession = await response.json()
        if (json.mediaItemsSet) {
          // console.log('User picked!', json)
          const resolvedPhotos = await resolvePickedPhotos({
            accessToken,
            pickingSession,
            signal,
          })
          onPickingSessionClear()
          onFilesPicked(resolvedPhotos, accessToken)
        }
        if (pickingSession.pollingConfig.timeoutIn === '0s') {
          onPickingSessionClear()
        }
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        return
      }
      // just report the error and continue polling
      onError(err)
    }
  }
}

export const mapPickerFile = (
  {
    requestClientId,
    accessToken,
    companionUrl,
  }: {
    requestClientId: string
    accessToken: string
    companionUrl: string
  },
  { id, mimeType, name, platform, ...rest }: PickedItem,
) => ({
  name,
  type: mimeType,
  data: {
    size: null, // defer to companion to determine size
  },
  isRemote: true,
  remote: {
    companionUrl,
    url: `${companionUrl}/google-picker/get`,
    body: {
      fileId: id,
      accessToken,
      platform,
      ...('url' in rest && { url: rest.url }),
    },
    requestClientId,
  },
  ...(('metadata' in rest && {
    meta: rest.metadata,
  }) as Meta), // dunno how to type this
})

export function createGooglePickerStoreAdapter({
  uppy,
  getPluginState,
  setPluginState,
}: {
  uppy: Uppy<any, any>
  getPluginState: () => GooglePickerState
  setPluginState: (state: Partial<GooglePickerState>) => void
}) {
  return {
    getSnapshot: () => getPluginState(),
    setState: (
      updater:
        | GooglePickerState
        | ((prevState: GooglePickerState) => GooglePickerState),
    ) => {
      const newState =
        typeof updater === 'function' ? updater(getPluginState()) : updater

      setPluginState(newState)
    },
    subscribe: (listener: (state: GooglePickerState) => void) => {
      const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
        _prev,
        _next,
        patch,
      ): void => {
        const screenCapturePatch = (patch?.plugins?.GoogleDrivePicker ??
          patch?.plugins?.GooglePhotosPicker) as GooglePickerState | undefined

        if (screenCapturePatch) listener(screenCapturePatch)
      }

      uppy.on('state-update', onStateUpdate)
      return () => uppy.off('state-update', onStateUpdate)
    },
  }
}

export type GooglePickerState = {
  loading: boolean
  accessToken: string | null | undefined
}

export type GooglePickerStore = ReturnType<
  typeof createGooglePickerStoreAdapter
>

export interface GooglePickerOptions
  extends Pick<RequestClientOptions, 'companionUrl'>,
    Pick<CompanionPluginOptions, 'storage'> {
  pickerType: GooglePickerType
  clientId: string
  requestClientId: string
  apiKey?: string
  appId?: string
  store: GooglePickerStore
}

export function createGooglePickerController({
  uppy,
  store,
  storage: persistentStore = tokenStorage,
  pickerType,
  companionUrl,
  requestClientId = pickerType === 'drive'
    ? 'GoogleDrivePicker'
    : 'GooglePhotosPicker',
  clientId,
  apiKey,
  appId,
}: {
  uppy: Uppy
} & GooglePickerOptions) {
  const storageKey = `uppy:google-${pickerType}-picker:accessToken`

  let abortController = new AbortController()
  let initPromise: Promise<void> | undefined
  let pickingSession: PickingSession | undefined

  const handleFilesPicked = async (
    files: PickedItem[],
    accessToken: string,
  ) => {
    uppy.addFiles(
      files.map((file) =>
        mapPickerFile({ requestClientId, accessToken, companionUrl }, file),
      ),
    )
  }

  function setAccessToken(newAccessToken: string | null) {
    store.setState((s) => ({ ...s, accessToken: newAccessToken }))
    if (newAccessToken == null) {
      return persistentStore.removeItem(storageKey)
    }
    return persistentStore.setItem(storageKey, newAccessToken)
  }

  function setLoading(v: boolean) {
    store.setState((s) => ({ ...s, loading: v }))
  }

  async function init() {
    if (initPromise == null) {
      initPromise = (async () => {
        abortController = new AbortController()

        setAccessToken(await persistentStore.getItem(storageKey))

        abortController.signal.throwIfAborted()

        // For google photos we need to continuously poll the current picking session
        if (pickerType === 'photos') {
          pollPickingSession({
            getPickingSession: () => pickingSession,
            getAccessToken: () => store.getSnapshot().accessToken,
            onPickingSessionClear: () => {
              pickingSession = undefined
            },
            signal: abortController.signal,
            onFilesPicked: handleFilesPicked,
            onError: (err) => uppy.log(err),
          })
        }
      })()
    }
    await initPromise
  }

  async function showPicker() {
    await init()

    let newAccessToken = store.getSnapshot().accessToken

    const doShowPicker = async (token: string) => {
      if (pickerType === 'drive') {
        if (apiKey == null || appId == null)
          throw new TypeError(
            'apiKey and appId are required for Google Drive picker',
          )
        await showDrivePicker({
          token,
          apiKey,
          appId,
          onFilesPicked: handleFilesPicked,
          signal: abortController.signal,
          onLoadingChange: (isLoading: boolean) => setLoading(isLoading),
          onError: (err: unknown) => {
            uppy.log(err)
            uppy.info(uppy.i18n('failedToAddFiles'), 'error')
          },
        })
      } else {
        // photos
        const onPickingSessionChange = (newPickingSession: PickingSession) => {
          pickingSession = newPickingSession
        }
        await showPhotosPicker({
          token,
          pickingSession,
          onPickingSessionChange,
          signal: abortController.signal,
        })
      }
    }

    setLoading(true)
    try {
      try {
        await ensureScriptsInjected(pickerType)

        if (newAccessToken == null) {
          newAccessToken = await authorize({ clientId, pickerType })
        }
        if (newAccessToken == null) throw new Error()

        await doShowPicker(newAccessToken)
        setAccessToken(newAccessToken)
      } catch (err) {
        if (err instanceof InvalidTokenError) {
          uppy.log('Token is invalid or expired, reauthenticating')
          newAccessToken = await authorize({
            pickerType,
            accessToken: newAccessToken,
            clientId,
          })
          // now try again:
          await doShowPicker(newAccessToken)
          setAccessToken(newAccessToken)
        } else {
          throw err
        }
      }
    } catch (err) {
      if (
        err instanceof Error &&
        'type' in err &&
        err.type === 'popup_closed'
      ) {
        // user closed the auth popup, ignore
      } else {
        setAccessToken(null)
        uppy.log(err)
      }
    } finally {
      setLoading(false)
    }
  }

  async function logout() {
    const { accessToken } = store.getSnapshot()
    if (accessToken) {
      await doLogout(accessToken)
      setAccessToken(null)
      pickingSession = undefined
    }
  }

  function reset() {
    abortController.abort()

    pickingSession = undefined
    initPromise = undefined
    store.setState((s) => ({ ...s, accessToken: undefined, loading: false }))
  }

  return {
    init,
    show: showPicker,
    reset,
    logout,
  }
}
