/*
 This file is part of GNU Taler
 (C) 2022 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

import {
  assertUnreachable,
  HttpStatusCode,
  OperationOk,
  succeedOrThrow,
  succeedOrValue,
  TalerCoreBankHttpClient,
  TalerError,
  TalerExchangeHttpClient,
  TalerMerchantInstanceHttpClient,
  TalerUri,
  TalerUriAction,
  TalerUriParseError,
  TalerUris,
  TranslatedString
} from "@gnu-taler/taler-util";
import {
  BrowserFetchHttpLib,
  InternationalizationAPI,
  useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { css } from "@linaria/core";
import { styled } from "@linaria/react";
import jsQR, * as pr from "jsqr";
import { h, VNode } from "preact";
import { useRef, useState } from "preact/hooks";
import { EnabledBySettings } from "../components/EnabledBySettings.js";
import { Alert } from "../mui/Alert.js";
import { Button } from "../mui/Button.js";
import { Grid } from "../mui/Grid.js";
import { InputFile } from "../mui/InputFile.js";
import { TextField } from "../mui/TextField.js";

const QrCanvas = css`
  width: 80%;
  margin-left: auto;
  margin-right: auto;
  padding: 8px;
  background-color: black;
`;

const LINE_COLOR = "#FF3B58";

const Container = styled.div`
  display: flex;
  flex-direction: column;
  & > * {
    margin-bottom: 20px;
  }
`;

export interface Props {
  onDetected: (url: TalerUri) => void;
}

type XY = { x: number; y: number };

function drawLine(
  canvas: CanvasRenderingContext2D,
  begin: XY,
  end: XY,
  color: string,
) {
  canvas.beginPath();
  canvas.moveTo(begin.x, begin.y);
  canvas.lineTo(end.x, end.y);
  canvas.lineWidth = 4;
  canvas.strokeStyle = color;
  canvas.stroke();
}

function drawBox(context: CanvasRenderingContext2D, code: pr.QRCode) {
  drawLine(
    context,
    code.location.topLeftCorner,
    code.location.topRightCorner,
    LINE_COLOR,
  );
  drawLine(
    context,
    code.location.topRightCorner,
    code.location.bottomRightCorner,
    LINE_COLOR,
  );
  drawLine(
    context,
    code.location.bottomRightCorner,
    code.location.bottomLeftCorner,
    LINE_COLOR,
  );
  drawLine(
    context,
    code.location.bottomLeftCorner,
    code.location.topLeftCorner,
    LINE_COLOR,
  );
}

const SCAN_PER_SECONDS = 3;
const TIME_BETWEEN_FRAMES = 1000 / SCAN_PER_SECONDS;

async function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function drawIntoCanvasAndGetQR(
  tag: HTMLVideoElement | HTMLImageElement,
  canvas: HTMLCanvasElement,
): string | undefined {
  const context = canvas.getContext("2d");
  if (!context) {
    throw Error("no 2d canvas context");
  }
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.drawImage(tag, 0, 0, canvas.width, canvas.height);
  const imgData = context.getImageData(0, 0, canvas.width, canvas.height);
  const code = jsQR.default(imgData.data, canvas.width, canvas.height, {
    inversionAttempts: "attemptBoth",
  });
  if (code) {
    drawBox(context, code);
    return code.data;
  }
  return undefined;
}

async function readNextFrame(
  video: HTMLVideoElement,
  canvas: HTMLCanvasElement,
): Promise<string | undefined> {
  const requestFrame =
    "requestVideoFrameCallback" in video
      ? video.requestVideoFrameCallback.bind(video)
      : requestAnimationFrame;

  return new Promise<string | undefined>((ok, bad) => {
    requestFrame(() => {
      try {
        const code = drawIntoCanvasAndGetQR(video, canvas);
        ok(code);
      } catch (error) {
        bad(error);
      }
    });
  });
}

async function createCanvasFromVideo(
  video: HTMLVideoElement,
  canvas: HTMLCanvasElement,
): Promise<string> {
  const context = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  if (!context) {
    throw Error("no 2d canvas context");
  }
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  let last = Date.now();

  let found: string | undefined = undefined;
  while (!found) {
    const timeSinceLast = Date.now() - last;
    if (timeSinceLast < TIME_BETWEEN_FRAMES) {
      await delay(TIME_BETWEEN_FRAMES - timeSinceLast);
    }
    last = Date.now();
    found = await readNextFrame(video, canvas);
  }
  video.pause();
  return found;
}

async function createCanvasFromFile(
  source: string,
  canvas: HTMLCanvasElement,
): Promise<string | undefined> {
  const img = new Image(300, 300);
  img.src = source;
  canvas.width = img.width;
  canvas.height = img.height;
  return new Promise<string | undefined>((ok, bad) => {
    img.addEventListener("load", () => {
      try {
        const code = drawIntoCanvasAndGetQR(img, canvas);
        ok(code);
      } catch (error) {
        bad(error);
      }
    });
  });
}

async function waitUntilReady(video: HTMLVideoElement): Promise<void> {
  return new Promise((ok, _bad) => {
    if (video.readyState === video.HAVE_ENOUGH_DATA) {
      return ok();
    }
    setTimeout(waitUntilReady, 100);
  });
}

function debounce(func: typeof testValidUri, wait = 500): typeof testValidUri {
  let timeout: ReturnType<typeof setTimeout>;
  type Args = Parameters<typeof testValidUri>;
  type Ret = ReturnType<typeof testValidUri>;

  function debounced(...args: Args): Promise<Ret> {
    clearTimeout(timeout);
    // FIXME: this should race
    return new Promise<Ret>((res, rej) => {
      timeout = setTimeout(() => {
        func(...args)
          .then((msg) => {
            res(Promise.resolve(msg));
          })
          .catch(rej);
      }, wait);
    });
  }

  return debounced as any;
}

const testValidUriDebounced = debounce(testValidUri);

type FailCasesOf<T extends (...args: any) => any> = Exclude<
  ReturnType<T>,
  OperationOk<any>
>;

function translateTalerUriError(
  result: FailCasesOf<typeof TalerUris.fromString>,
  i18n: InternationalizationAPI,
): TranslatedString {
  switch (result.case) {
    case TalerUriParseError.WRONG_PREFIX:
      return i18n.str`URI is not valid. Taler URI should start with "taler://"`;
    case TalerUriParseError.INCOMPLETE:
      return i18n.str`After the URI type it should follow a '/' with more information.`;
    case TalerUriParseError.UNSUPPORTED:
      return i18n.str`This URI type is not supported`;
    case TalerUriParseError.COMPONENTS_LENGTH: {
      switch (result.body.uriType) {
        case TalerUriAction.Withdraw:
          return i18n.str`This URI requires the bank host and operation id separated by /`;
        case TalerUriAction.Pay:
          return i18n.str`This URI requires the merchant host, order id and session id separated by /`;
        case TalerUriAction.Refund:
          return i18n.str`This URI requires the merchant host and order id separated by /`;
        case TalerUriAction.PayPush:
          return i18n.str`This URI requires the exchange host and key id separated by /`;
        case TalerUriAction.PayPull:
          return i18n.str`This URI requires the exchange host and key id separated by /`;
        case TalerUriAction.PayTemplate:
          return i18n.str`This URI requires the merchant host and template id separated by /`;
        case TalerUriAction.Restore:
          return i18n.str`This URI requires the host key and provider list separated by /`;
        case TalerUriAction.DevExperiment:
          return i18n.str`This URI requires the experiment id`;
        case TalerUriAction.AddExchange:
          return i18n.str`This URI requires the exchange host`;
        case TalerUriAction.AddContact:
          return i18n.str`This URI requires the alias type, the alias and the mailbox URI separated by /`;
        case TalerUriAction.WithdrawExchange:
          return i18n.str`This URI requires the exchange host`;
        case TalerUriAction.WithdrawalTransferResult:
          return i18n.str`This URI requires string after the first /`;
      }
    }
    case TalerUriParseError.INVALID_TARGET_PATH: {
      switch (result.body.uriType) {
        case TalerUriAction.Withdraw:
          return i18n.str`The bank host is invalid`;
        case TalerUriAction.Pay:
          return i18n.str`The merchant host is invalid`;
        case TalerUriAction.Refund: {
          switch (result.body.pos) {
            case 0:
              return i18n.str`The merchant host is invalid`;
            case 1:
              return i18n.str`The URI should end with /`;
          }
        }
        case TalerUriAction.PayPush:
          return i18n.str`The exchange host is invalid`;
        case TalerUriAction.PayPull:
          return i18n.str`The exchange host is invalid`;
        case TalerUriAction.PayTemplate:
          return i18n.str`The merchant host is invalid`;
        case TalerUriAction.AddExchange:
          return i18n.str`The exchange host is invalid`;
        case TalerUriAction.AddContact:
          return i18n.str`The contact is invalid`;
        case TalerUriAction.WithdrawExchange:
          switch (result.body.pos) {
            case 0:
              return i18n.str`The exchange host is invalid`;
            case 1:
              return i18n.str`The URI should end with /`;
          }
      }
    }
    case TalerUriParseError.INVALID_PARAMETER: {
      switch (result.body.uriType) {
        case TalerUriAction.WithdrawExchange:
          return i18n.str`The amount is invalid`;
        case TalerUriAction.Withdraw:
        case TalerUriAction.Pay:
        case TalerUriAction.Refund:
        case TalerUriAction.PayPush:
        case TalerUriAction.PayPull:
        case TalerUriAction.PayTemplate:
        case TalerUriAction.AddExchange:
        case TalerUriAction.AddContact:
          return i18n.str`A parameter is invalid`;
      }
    }
  }
}

export function QrReaderPage({ onDetected }: Props): VNode {
  const videoRef = useRef<HTMLVideoElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [error, setError] = useState<TranslatedString | undefined>();
  const [value, setValue] = useState("");
  const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing");

  const { i18n } = useTranslationContext();

  async function onChangeDetect(str: string) {
    if (str) {
      const lstr = str.toLowerCase();
      const uriResp = TalerUris.fromString(lstr);

      if (uriResp.type === "fail") {
        setError(translateTalerUriError(uriResp, i18n));
        setValue(str);
        return;
      }
      const { body: uri } = uriResp;
      setError(i18n.str`checking...`);
      const errorMsg = await testValidUriDebounced(uri, i18n);
      if (errorMsg) {
        setError(errorMsg);
        setValue(str);
        return;
      }
      onDetected(uri);
      setError(undefined);
      setValue(str);
    } else {
      setError(undefined);
      setValue(str);
    }
  }

  function onChange(str: string) {
    if (str) {
      const lstr = str.toLowerCase();
      const uriResp = TalerUris.fromString(lstr);

      if (uriResp.type === "fail") {
        setError(translateTalerUriError(uriResp, i18n));
        setValue(str);
        return;
      }
      const { body: uri } = uriResp;

      setError(i18n.str`checking...`);
      setValue(str);
      testValidUriDebounced(uri, i18n).then((errorMsg) => {
        if (errorMsg) {
          setError(errorMsg);
          return;
        }
        setError(undefined);
      });
    } else {
      setError(undefined);
      setValue(str);
    }
  }

  async function startVideo() {
    if (!videoRef.current || !canvasRef.current) {
      return;
    }
    const video = videoRef.current;
    if (!video || !video.played) return;
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: "environment" },
      audio: false,
    });
    setShow("video");
    setError(undefined);
    video.srcObject = stream;
    await video.play();
    await waitUntilReady(video);
    try {
      const code = await createCanvasFromVideo(video, canvasRef.current);
      if (code) {
        await onChangeDetect(code);
        setShow("canvas");
      }
      stream.getTracks().forEach((e) => {
        e.stop();
      });
    } catch (error) {
      setError(i18n.str`Unexpected error happen reading the camera: ${error}`);
    }
  }

  async function onFileRead(fileContent: string) {
    if (!canvasRef.current) {
      return;
    }
    setShow("nothing");
    setError(undefined);
    try {
      const code = await createCanvasFromFile(fileContent, canvasRef.current);
      if (code) {
        await onChangeDetect(code);
        setShow("canvas");
      } else {
        setError(i18n.str`Could not found a QR code in the file`);
      }
    } catch (error) {
      setError(i18n.str`Unexpected error happen reading the file: ${error}`);
    }
  }
  const uri = succeedOrValue(TalerUris.fromString(value.toLowerCase()), undefined);

  return (
    <Container>
      <section>
        <h1>
          <i18n.Translate>
            Scan a QR code or enter taler:// URI below
          </i18n.Translate>
        </h1>
        <div style={{ justifyContent: "space-between", display: "flex" }}>
          <div style={{ width: "75%" }}>
            <TextField
              label="Taler URI"
              variant="filled"
              fullWidth
              value={value}
              onChange={onChange}
            />
          </div>
        </div>
        <Grid container justifyContent="space-around" columns={2}>
          <Grid item xs={2}>
            <p>{error && <Alert severity="error">{error}</Alert>}</p>
          </Grid>
          {uri && (
            <Grid item xs={2}>
              <p>
                <Button
                  disabled={!!error}
                  variant="contained"
                  color="success"
                  onClick={async () => {
                    if (uri) onDetected(uri);
                  }}
                >
                  {(function (talerUri: TalerUri): VNode {
                    switch (talerUri.type) {
                      case TalerUriAction.Pay:
                        return <i18n.Translate>Pay invoice</i18n.Translate>;
                      case TalerUriAction.Withdraw:
                        return (
                          <i18n.Translate>Withdrawal from bank</i18n.Translate>
                        );
                      case TalerUriAction.Refund:
                        return <i18n.Translate>Claim refund</i18n.Translate>;
                      case TalerUriAction.PayPull:
                        return <i18n.Translate>Pay invoice</i18n.Translate>;
                      case TalerUriAction.PayPush:
                        return <i18n.Translate>Accept payment</i18n.Translate>;
                      case TalerUriAction.PayTemplate:
                        return <i18n.Translate>Complete order</i18n.Translate>;
                      case TalerUriAction.Restore:
                        return <i18n.Translate>Restore wallet</i18n.Translate>;
                      case TalerUriAction.DevExperiment:
                        return (
                          <i18n.Translate>Enable experiment</i18n.Translate>
                        );
                      case TalerUriAction.WithdrawExchange:
                        return (
                          <i18n.Translate>
                            Withdraw from exchange
                          </i18n.Translate>
                        );
                      case TalerUriAction.AddExchange:
                        return <i18n.Translate>Add exchange</i18n.Translate>;
                      case TalerUriAction.WithdrawalTransferResult:
                        return (
                          <i18n.Translate>Notify transaction</i18n.Translate>
                        );
                      case TalerUriAction.AddContact:
                        return <i18n.Translate>Add contact</i18n.Translate>;
                      default: {
                        assertUnreachable(talerUri);
                      }
                    }
                  })(uri)}
                </Button>
              </p>
            </Grid>
          )}
          <Grid item xs={2}>
            <p>
              <Button variant="contained" onClick={startVideo}>
                Use Camera
              </Button>
            </p>
          </Grid>
          <EnabledBySettings name="advancedMode">
            <Grid item xs={2}>
              <InputFile onChange={onFileRead}>Read QR from file</InputFile>
            </Grid>
          </EnabledBySettings>
        </Grid>
      </section>
      <div>
        <video
          ref={videoRef}
          style={{ display: show === "video" ? "unset" : "none" }}
          playsInline={true}
        />
        <canvas
          id="este"
          class={QrCanvas}
          ref={canvasRef}
          style={{ display: show === "canvas" ? "unset  " : "none" }}
        />
      </div>
    </Container>
  );
}
const httpClient = new BrowserFetchHttpLib();

async function testValidUri(
  uri: TalerUri,
  i18n: InternationalizationAPI,
): Promise<TranslatedString | undefined> {
  switch (uri.type) {
    case TalerUriAction.Restore:
    case TalerUriAction.DevExperiment:
    case TalerUriAction.AddContact:
    case TalerUriAction.WithdrawalTransferResult: {
      return undefined;
    }
    case TalerUriAction.Withdraw: {
      const errorExchange = await checkBankUrl(
        uri.bankIntegrationApiBaseUrl,
        i18n,
      );
      if (errorExchange) {
        return errorExchange;
      }
      return undefined;
    }
    case TalerUriAction.Refund:
    case TalerUriAction.PayTemplate:
    case TalerUriAction.Pay: {
      const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n);
      if (errorExchange) {
        return errorExchange;
      }
      return undefined;
    }
    case TalerUriAction.PayPush:
    case TalerUriAction.AddExchange:
    case TalerUriAction.WithdrawExchange:
    case TalerUriAction.PayPull: {
      const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n);
      if (errorExchange) {
        return errorExchange;
      }
      return undefined;
    }
    default: {
      assertUnreachable(uri);
    }
  }
}

async function checkExchangeUrl(
  baseUrl: string,
  i18n: InternationalizationAPI,
): Promise<TranslatedString | undefined> {
  let url: URL | undefined = undefined;
  try {
    url = new URL("./", baseUrl);
  } catch (e) {
    return i18n.str`The exchange URL is invalid.`;
  }
  if (!baseUrl.endsWith("/")) {
    return i18n.str`The exchange URL should end with '/'`;
  }
  try {
    const config = await new TalerExchangeHttpClient(url.href, {
      httpClient,
    }).getConfig();
    if (config.type === "ok") {
      return undefined;
    } else {
      switch (config.case) {
        case HttpStatusCode.NotFound: {
          return i18n.str`Couldn't found an exchange in the URL specified.`;
        }
        default: {
          assertUnreachable(config);
        }
      }
    }
  } catch (e) {
    if (e instanceof TalerError && e.errorDetail.detail) {
      return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`;
    }
    return i18n.str`HTTP request failed to ${url.href}`;
  }
}

async function checkMerchantUrl(
  baseUrl: string,
  i18n: InternationalizationAPI,
): Promise<TranslatedString | undefined> {
  let url: URL | undefined = undefined;
  try {
    url = new URL("./", baseUrl);
  } catch (e) {
    return i18n.str`The merchant URL is invalid.`;
  }
  if (!baseUrl.endsWith("/")) {
    return i18n.str`The merchant URL should end with '/'`;
  }
  try {
    const config = await new TalerMerchantInstanceHttpClient(
      url.href,
      httpClient,
    ).getConfig();
    if (config.type === "ok") {
      return undefined;
    } else {
      switch (config.case) {
        case HttpStatusCode.NotFound: {
          return i18n.str`Couldn't found an merchant in the URL specified.`;
        }
        default: {
          assertUnreachable(config);
        }
      }
    }
  } catch (e) {
    if (e instanceof TalerError && e.errorDetail.detail) {
      return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`;
    }
    return i18n.str`HTTP request failed to ${url.href}`;
  }
}

async function checkBankUrl(
  baseUrl: string,
  i18n: InternationalizationAPI,
): Promise<TranslatedString | undefined> {
  let url: URL | undefined = undefined;
  try {
    url = new URL("./", baseUrl);
  } catch (e) {
    return i18n.str`The bank URL is invalid.`;
  }
  if (!baseUrl.endsWith("/")) {
    return i18n.str`The bank URL should end with '/'`;
  }
  try {
    const config = await new TalerCoreBankHttpClient(
      url.href,
      httpClient,
    ).getConfig();
    if (config.type === "ok") {
      return undefined;
    } else {
      switch (config.case) {
        case HttpStatusCode.NotFound: {
          return i18n.str`Couldn't found an bank in the URL specified.`;
        }
        default: {
          assertUnreachable(config);
        }
      }
    }
  } catch (e) {
    if (e instanceof TalerError && e.errorDetail.detail) {
      return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`;
    }
    return i18n.str`HTTP request failed to ${url.href}`;
  }
}
