import { type MutableRef } from 'preact/hooks'

// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems
export interface MediaItemBase {
  id: string
  createTime: string
}

interface MediaFileMetadataBase {
  width: number
  height: number
  cameraMake: string
  cameraModel: string
}

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

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

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

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

export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem

// 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
}

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)
}

export 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
}

export 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
}

export async function logout(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'
  }
}

export async function showDrivePicker({
  token,
  apiKey,
  appId,
  onFilesPicked,
  signal,
}: {
  token: string
  apiKey: string
  appId: string
  onFilesPicked: (files: PickedItem[], accessToken: string) => void
  signal: AbortSignal | undefined
}): 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 = (picked: google.picker.ResponseObject) => {
    if (picked.action === google.picker.Action.PICKED) {
      // console.log('Picker response', JSON.stringify(picked, null, 2));
      onFilesPicked(
        picked['docs'].map((doc) => ({
          platform: 'drive',
          id: doc['id'],
          name: doc['name'],
          mimeType: doc['mimeType'],
        })),
        token,
      )
    }
  }

  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(false)
        .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())
}

export 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) }).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)

  // todo show alert instead about invalid picked files?
  mediaItems = mediaItems.flatMap((i) =>
    (
      i.type === 'PHOTO' ||
      (i.type === 'VIDEO' &&
        i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus ===
          'READY')
    ) ?
      [i]
    : [],
  )

  return mediaItems.map(
    ({
      id,
      type,
      // 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
      mediaFile: { mimeType, filename, baseUrl },
    }) => ({
      platform: 'photos' as const,
      id,
      mimeType,
      url: type === 'VIDEO' ? `${baseUrl}=dv` : baseUrl, // dv to download video
      name: filename,
    }),
  )
}

export async function pollPickingSession({
  pickingSessionRef,
  accessTokenRef,
  signal,
  onFilesPicked,
  onError,
}: {
  pickingSessionRef: MutableRef<PickingSession | undefined>
  accessTokenRef: MutableRef<string | null | undefined>
  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 {
      if (pickingSessionRef.current != null) {
        interval = parseFloat(
          pickingSessionRef.current.pollingConfig.pollInterval,
        )
      } else {
        interval = 1
      }

      await Promise.race([
        new Promise((resolve) => setTimeout(resolve, interval * 1000)),
        new Promise((_resolve, reject) => {
          signal.addEventListener('abort', reject)
        }),
      ])

      signal.throwIfAborted()

      const accessToken = accessTokenRef.current
      const pickingSession = pickingSessionRef.current

      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,
          })
          // eslint-disable-next-line no-param-reassign
          pickingSessionRef.current = undefined
          onFilesPicked(resolvedPhotos, accessToken)
        }
        if (pickingSession.pollingConfig.timeoutIn === '0s') {
          // eslint-disable-next-line no-param-reassign
          pickingSessionRef.current = undefined
        }
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        return
      }
      // just report the error and continue polling
      onError(err)
    }
  }
}
