/* eslint-env browser */
import * as CapabilityUtils from './WebCapabilityUtils';
import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants';
import { requestUserMediaAsync } from './WebUserMediaManager';
import {
  CameraType,
  CameraCapturedPicture,
  ImageSize,
  ImageType,
  WebCameraSettings,
  CameraPictureOptions,
} from '../Camera.types';

interface ConstrainLongRange {
  max?: number;
  min?: number;
  exact?: number;
  ideal?: number;
}

export function getImageSize(videoWidth: number, videoHeight: number, scale: number): ImageSize {
  const width = videoWidth * scale;
  const ratio = videoWidth / width;
  const height = videoHeight / ratio;

  return {
    width,
    height,
  };
}

export function toDataURL(
  canvas: HTMLCanvasElement,
  imageType: ImageType,
  quality: number
): string {
  const format = ImageTypeFormat[imageType];
  if (imageType === 'jpg') {
    return canvas.toDataURL(format, quality);
  } else {
    return canvas.toDataURL(format);
  }
}

export function hasValidConstraints(
  preferredCameraType?: CameraType,
  width?: number | ConstrainLongRange,
  height?: number | ConstrainLongRange
): boolean {
  return preferredCameraType !== undefined && width !== undefined && height !== undefined;
}

function ensureCameraPictureOptions(config: CameraPictureOptions): CameraPictureOptions {
  const captureOptions: CameraPictureOptions = {
    scale: 1,
    imageType: 'png' as ImageType,
    isImageMirror: false,
  };
  for (const key in config) {
    const prop = key as keyof CameraPictureOptions;
    if (prop in config && config[prop] !== undefined && prop in captureOptions) {
      captureOptions[prop] = config[prop] as any;
    }
  }
  return captureOptions;
}

const DEFAULT_QUALITY = 0.92;

export function captureImageContext(
  video: HTMLVideoElement,
  { scale = 1, isImageMirror = false }: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>
): HTMLCanvasElement {
  const { videoWidth, videoHeight } = video;
  const { width, height } = getImageSize(videoWidth, videoHeight, scale!);

  // Build the canvas size and draw the camera image to the context from video
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext('2d', { alpha: false });

  if (!context) {
    // Should never be called
    throw new Error('Context is not defined');
  }
  // sharp image details
  // context.imageSmoothingEnabled = false;

  // Flip horizontally (as css transform: rotateY(180deg))
  if (isImageMirror) {
    context.setTransform(-1, 0, 0, 1, canvas.width, 0);
  }

  context.drawImage(video, 0, 0, width, height);

  return canvas;
}

export function captureImage(
  video: HTMLVideoElement,
  pictureOptions: CameraPictureOptions
): string {
  const config = ensureCameraPictureOptions(pictureOptions);
  const canvas = captureImageContext(video, config);
  const { imageType, quality = DEFAULT_QUALITY } = config;
  return toDataURL(canvas, imageType!, quality);
}

function getSupportedConstraints(): MediaTrackSupportedConstraints | null {
  if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) {
    return navigator.mediaDevices.getSupportedConstraints();
  }
  return null;
}

export function getIdealConstraints(
  preferredCameraType: CameraType,
  width?: number | ConstrainLongRange,
  height?: number | ConstrainLongRange
): MediaStreamConstraints {
  const preferredConstraints: MediaStreamConstraints = {
    audio: false,
    video: {},
  };

  if (hasValidConstraints(preferredCameraType, width, height)) {
    return MinimumConstraints;
  }

  const supports = getSupportedConstraints();
  // TODO(Bacon): Test this
  if (!supports || !supports.facingMode || !supports.width || !supports.height) {
    return MinimumConstraints;
  }
  const types = ['front', 'back'];
  if (preferredCameraType && types.includes(preferredCameraType)) {
    const facingMode = CameraTypeToFacingMode[preferredCameraType];
    if (isWebKit()) {
      const key = facingMode === 'user' ? 'exact' : 'ideal';
      (preferredConstraints.video as MediaTrackConstraints).facingMode = {
        [key]: facingMode,
      };
    } else {
      (preferredConstraints.video as MediaTrackConstraints).facingMode = {
        ideal: CameraTypeToFacingMode[preferredCameraType],
      };
    }
  }

  if (isMediaTrackConstraints(preferredConstraints.video)) {
    preferredConstraints.video.width = width;
    preferredConstraints.video.height = height;
  }

  return preferredConstraints;
}

function isMediaTrackConstraints(input: any): input is MediaTrackConstraints {
  return input && typeof input.video !== 'boolean';
}

/**
 * Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved.
 *
 * @param preferredCameraType
 * @param preferredWidth
 * @param preferredHeight
 */
export async function getPreferredStreamDevice(
  preferredCameraType: CameraType,
  preferredWidth?: number | ConstrainLongRange,
  preferredHeight?: number | ConstrainLongRange
): Promise<MediaStream> {
  try {
    return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight);
  } catch (error) {
    // A hack on desktop browsers to ensure any camera is used.
    // eslint-disable-next-line no-undef
    if (error instanceof OverconstrainedError && error.constraint === 'facingMode') {
      const nextCameraType = preferredCameraType === 'back' ? 'front' : 'back';
      return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight);
    }
    throw error;
  }
}

export async function getStreamDevice(
  preferredCameraType: CameraType,
  preferredWidth?: number | ConstrainLongRange,
  preferredHeight?: number | ConstrainLongRange
): Promise<MediaStream> {
  const constraints: MediaStreamConstraints = getIdealConstraints(
    preferredCameraType,
    preferredWidth,
    preferredHeight
  );
  const stream: MediaStream = await requestUserMediaAsync(constraints);
  return stream;
}

export function isWebKit(): boolean {
  return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent);
}

export function compareStreams(a: MediaStream | null, b: MediaStream | null): boolean {
  if (!a || !b) {
    return false;
  }
  const settingsA = a.getTracks()[0].getSettings();
  const settingsB = b.getTracks()[0].getSettings();
  return settingsA.deviceId === settingsB.deviceId;
}

export function capture(
  video: HTMLVideoElement,
  settings: MediaTrackSettings,
  config: CameraPictureOptions
): CameraCapturedPicture {
  const base64 = captureImage(video, config);

  const capturedPicture: CameraCapturedPicture = {
    uri: base64,
    base64,
    width: 0,
    height: 0,
    format: config.imageType ?? 'jpg',
  };

  if (settings) {
    const { width = 0, height = 0 } = settings;
    capturedPicture.width = width;
    capturedPicture.height = height;
    capturedPicture.exif = settings;
  }

  if (config.onPictureSaved) {
    config.onPictureSaved(capturedPicture);
  }
  return capturedPicture;
}

export async function syncTrackCapabilities(
  cameraType: CameraType,
  stream: MediaStream | null,
  settings: WebCameraSettings = {}
): Promise<void> {
  if (stream?.getVideoTracks) {
    await Promise.all(
      stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings))
    );
  }
}

// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
async function onCapabilitiesReady(
  cameraType: CameraType,
  track: MediaStreamTrack,
  settings: WebCameraSettings = {}
): Promise<void> {
  if (typeof track.getCapabilities !== 'function') {
    return;
  }

  const capabilities = track.getCapabilities();

  // Create an empty object because if you set a constraint that isn't available an error will be thrown.
  const constraints: MediaTrackConstraintSet = {};

  // TODO(Bacon): Add `pointsOfInterest` support
  const clampedValues = [
    'exposureCompensation',
    'colorTemperature',
    'iso',
    'brightness',
    'contrast',
    'saturation',
    'sharpness',
    'focusDistance',
    'zoom',
  ] as const;

  for (const property of clampedValues) {
    if (capabilities[property]) {
      constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]);
    }
  }

  function validatedInternalConstrainedValue<IConvertedType>(
    constraintKey: keyof MediaTrackCapabilities,
    settingsKey: keyof WebCameraSettings,
    converter: (settingValue: any) => IConvertedType
  ) {
    const convertedSetting = converter(settings[settingsKey]);
    return validatedConstrainedValue({
      constraintKey,
      settingsKey,
      convertedSetting,
      capabilities,
      settings,
      cameraType,
    });
  }

  if (capabilities.focusMode && settings.autoFocus !== undefined) {
    constraints.focusMode = validatedInternalConstrainedValue<MediaTrackConstraintSet['focusMode']>(
      'focusMode',
      'autoFocus',
      CapabilityUtils.convertAutoFocusJSONToNative
    );
  }

  if (capabilities.torch && settings.flashMode !== undefined) {
    constraints.torch = validatedInternalConstrainedValue<MediaTrackConstraintSet['torch']>(
      'torch',
      'flashMode',
      CapabilityUtils.convertFlashModeJSONToNative
    );
  }

  if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) {
    constraints.whiteBalanceMode = validatedInternalConstrainedValue<
      MediaTrackConstraintSet['whiteBalanceMode']
    >('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative);
  }

  try {
    await track.applyConstraints({ advanced: [constraints] });
  } catch (error) {
    if (__DEV__) console.warn('Failed to apply constraints', error);
  }
}

export function stopMediaStream(stream: MediaStream | null) {
  if (!stream) {
    return;
  }
  stream.getAudioTracks().forEach((track) => track.stop());
  stream.getVideoTracks().forEach((track) => track.stop());
}

export function setVideoSource(video: HTMLVideoElement, stream: MediaStream | null): void {
  video.srcObject = stream;
}

export function isCapabilityAvailable(
  video: HTMLVideoElement,
  keyName: keyof MediaTrackCapabilities
): boolean {
  const stream = video.srcObject;

  if (stream instanceof MediaStream) {
    const videoTrack = stream.getVideoTracks()[0];
    return !!videoTrack.getCapabilities?.()?.[keyName];
  }

  return false;
}

function convertNormalizedSetting(range: MediaSettingsRange, value?: number): number | undefined {
  if (!value) {
    return;
  }
  // TODO(@kitten): Handle undefined values / normalize explicitly
  // convert the normalized incoming setting to the native camera zoom range
  const converted = convertRange(value, [range.min!, range.max!]);
  // clamp value so we don't get an error
  return Math.min(range.max!, Math.max(range.min!, converted));
}

function convertRange(value: number, r2: [number, number], r1: [number, number] = [0, 1]): number {
  return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0];
}

function validatedConstrainedValue<T>(props: {
  constraintKey: keyof MediaTrackCapabilities;
  settingsKey: keyof WebCameraSettings;
  convertedSetting: T;
  capabilities: MediaTrackCapabilities;
  settings: WebCameraSettings;
  cameraType: string;
}): T | undefined {
  const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } =
    props;
  const setting = settings[settingsKey];
  if (
    Array.isArray(capabilities[constraintKey]) &&
    convertedSetting &&
    !capabilities[constraintKey].includes(convertedSetting)
  ) {
    if (__DEV__) {
      // Only warn in dev mode.
      console.warn(
        ` { ${settingsKey}: "${setting}" } (converted to "${convertedSetting}" in the browser) is not supported for camera type "${cameraType}" in your browser. Using the default value instead.`
      );
    }
    return undefined;
  }
  return convertedSetting;
}
