import {
  StreamApiNotSupportedError,
  InsecureContextError,
} from "./errors";
import { eventOn, timeout } from "./util";
import shimGetUserMedia from "./shimGetUserMedia";

export class Camera {
  videoEl: HTMLVideoElement;
  stream: MediaStream;

  constructor(videoEl: HTMLVideoElement, stream: MediaStream) {
    this.videoEl = videoEl;
    this.stream = stream;
  }

  stop(): void {
    this.videoEl.srcObject = null;

    this.stream.getTracks().forEach((track) => {
      this.stream.removeTrack(track);
      track.stop();
    });
  }

  getCapabilities(): MediaTrackCapabilities {
    const [track] = this.stream.getVideoTracks();
    // Firefox does not yet support getCapabilities as of August 2020
    return track?.getCapabilities?.() ?? ({} as MediaTrackCapabilities);
  }
}

type CameraMode = "auto" | "rear" | "front" | "off";

type LegacyVideoElement = HTMLVideoElement & {
  mozSrcObject?: MediaStream;
};

type VideoElementWithOptionalSrcObject = HTMLVideoElement & {
  srcObject?: MediaStream | null;
};

type ExtendedWindow = Window & {
  webkitURL?: typeof window.URL;
};

type TorchCapabilities = MediaTrackCapabilities & {
  torch?: boolean;
};

type TorchConstraintSet = MediaTrackConstraintSet & {
  torch?: boolean;
};

const narrowDownFacingMode = async (
  camera: CameraMode
): Promise<
  | { deviceId: { exact: string } }
  | { facingMode: { ideal: string } | { exact: string } }
  | undefined
> => {
  // Modern phones often have multipe front/rear cameras.
  // Sometimes special purpose cameras like the wide-angle camera are picked
  // by default. Those are not optimal for scanning QR codes but standard
  // media constraints don't allow us to specify which camera we want exactly.
  // However, explicitly picking the first entry in the list of all videoinput
  // devices for as the default front camera and the last entry as the default
  // rear camera seems to be a workaround.
  const devices = (await navigator.mediaDevices.enumerateDevices()).filter(
    ({ kind }) => kind === "videoinput"
  );

  if (devices.length >= 2) {
    const frontCamera = devices[0];
    const rearCamera = devices[devices.length - 1];

    switch (camera) {
      case "auto":
        return { deviceId: { exact: rearCamera.deviceId } };
      case "rear":
        return { deviceId: { exact: rearCamera.deviceId } };
      case "front":
        return { deviceId: { exact: frontCamera.deviceId } };
      default:
        return undefined;
    }
  } else {
    switch (camera) {
      case "auto":
        return { facingMode: { ideal: "environment" } };
      case "rear":
        return { facingMode: { exact: "environment" } };
      case "front":
        return { facingMode: { exact: "user" } };
      default:
        return undefined;
    }
  }
};

export interface CameraOptions {
  camera: CameraMode;
  torch: boolean;
}

export default async function (
  videoEl: HTMLVideoElement,
  { camera, torch }: CameraOptions
): Promise<Camera> {
  // At least in Chrome `navigator.mediaDevices` is undefined when the page is
  // loaded using HTTP rather than HTTPS. Thus `STREAM_API_NOT_SUPPORTED` is
  // initialized with `false` although the API might actually be supported.
  // So although `getUserMedia` already should have a built-in mechanism to
  // detect insecure context (by throwing `NotAllowedError`), we have to do a
  // manual check before even calling `getUserMedia`.
  if (window.isSecureContext !== true) {
    throw new InsecureContextError();
  }

  if (navigator?.mediaDevices?.getUserMedia === undefined) {
    throw new StreamApiNotSupportedError();
  }

  // This is a browser API only shim. It patches the global window object which
  // is not available during SSR. So we lazily apply this shim at runtime.
  await shimGetUserMedia();

  const constraints: MediaStreamConstraints = {
    audio: false,
    video: {
      width: { min: 360, ideal: 640, max: 1920 },
      height: { min: 240, ideal: 480, max: 1080 },
      ...(await narrowDownFacingMode(camera)),
    },
  };

  const stream = await navigator.mediaDevices.getUserMedia(constraints);

  const videoWithMoz = videoEl as LegacyVideoElement;
  const videoWithSrcObject = videoEl as VideoElementWithOptionalSrcObject;
  const windowWithWebkitURL = window as ExtendedWindow;

  if (videoWithSrcObject.srcObject !== undefined) {
    videoWithSrcObject.srcObject = stream;
  } else if (videoWithMoz.mozSrcObject !== undefined) {
    videoWithMoz.mozSrcObject = stream;
  } else if (typeof window.URL.createObjectURL === "function") {
    const objectUrl = window.URL.createObjectURL(
      stream as unknown as MediaSource
    );
    videoWithMoz.src = objectUrl;
  } else if (
    windowWithWebkitURL.webkitURL !== undefined &&
    typeof windowWithWebkitURL.webkitURL.createObjectURL === "function"
  ) {
    const objectUrl = windowWithWebkitURL.webkitURL.createObjectURL(
      stream as unknown as MediaSource
    );
    videoWithMoz.src = objectUrl;
  } else {
    videoWithMoz.src = stream as unknown as string;
  }

  await eventOn(videoEl, "loadeddata");

  // According to: https://oberhofer.co/mediastreamtrack-and-its-capabilities/#queryingcapabilities
  // On some devices, getCapabilities only returns a non-empty object after
  // some delay. There is no appropriate event so we have to add a constant timeout
  await timeout(500);

  if (torch) {
    const [track] = stream.getVideoTracks();
    const capabilities = track.getCapabilities();

    const torchCapabilities = capabilities as TorchCapabilities;

    if (torchCapabilities.torch) {
      const torchConstraint: TorchConstraintSet = { torch: true };
      track.applyConstraints({ advanced: [torchConstraint] });
    } else {
      console.warn("device does not support torch capability");
    }
  }

  return new Camera(videoEl, stream);
}
