import type { MutableRef } from 'preact/hooks'

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

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

  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
  }

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

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

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