// @ts-ignore
import {TextEncoder} from 'text-encoding';
import {
  Config,
  ErrorWithMessage,
  PasskeysAuthResult,
  SignupBeginResponse,
  TokenPrfResponse,
  PromiseResult,
  RegisterBeginResponse,
  Result,
  SigninBeginResponse,
  SigninMethod,
  GetUserPasskeyCredentialsBackendResponse,
  GetUserPasskeyCredentialsResult,
  DeleteUserPasskeyCredentialResult,
  PasskeysRegisterResult,
  VerifyReAuthResponse,
  PasskeysReAuthResult,
  VerifyAuthResponse,
} from './types';

export const ErrorCodes = {
  Unknown: 'Unknown',
  HTTP: 'HTTP',
  AbortError: 'AbortError',
  NotAllowedError: 'NotAllowedError',
  FailedCreateCredential: 'FailedCreateCredential',
  PassKeySignInVerificationFailed: 'PassKeySignInVerificationFailed',
  PRFNotSupported: 'PRFNotSupported',
  InvalidStateError: 'InvalidStateError',
};

export const ErrorFrom = {
  BrowserWebAuthn: 'BrowserWebAuthn',
  Backend: 'Backend',
  PasswordlessDev: 'PasswordlessDev',
  Unknown: 'Unknown',
};

export const prf_salt = 'passwordless-login';

export class PasskeysAuthError extends Error {
  from: string;
  errorCode: string;

  constructor(from: string, errorCode: string, message: string) {
    super(message);
    this.from = from;
    this.errorCode = errorCode;
  }
}

export class PasswordlessService {
  private abortController: AbortController = new AbortController();
  private config: Config;

  constructor(publicApiKey: string, backendApiRootUrl: string) {
    this.config = {
      apiUrl: 'https://v4.passwordless.dev',
      apiKey: publicApiKey,
      origin: window.location.origin,
      rpid: window.location.hostname,
      backendApiRootUrl: backendApiRootUrl,
    };
  }

  private createHeaders(): Record<string, string> {
    return {
      ApiKey: this.config.apiKey,
      'Content-Type': 'application/json',
      'Client-Version': 'js-1.1.0',
    };
  }

  public isBrowserSupported(): void {
    if (
      !(
        window.PublicKeyCredential !== undefined &&
        typeof window.PublicKeyCredential === 'function'
      )
    ) {
      throw new PasskeysAuthError(
        ErrorFrom.BrowserWebAuthn,
        ErrorCodes.Unknown,
        'WebAuthn and PublicKeyCredentials are not supported on this browser/device'
      );
    }
  }

  private async registerBegin(
    token: string
  ): PromiseResult<RegisterBeginResponse> {
    const response = await fetch(`${this.config.apiUrl}/register/begin`, {
      method: 'POST',
      headers: this.createHeaders(),
      body: JSON.stringify({
        token,
        RPID: this.config.rpid,
        Origin: this.config.origin,
      }),
    });

    const res = await response.json();
    if (response.ok) {
      return res;
    }

    throw new PasskeysAuthError(
      ErrorFrom.PasswordlessDev,
      ErrorCodes.HTTP,
      `${response.status} ${res.message}`
    );
  }

  private async registerComplete(
    credential: PublicKeyCredential,
    session: string,
    isPRFSupportRequired: boolean,
    nickname?: string
  ): PromiseResult<TokenPrfResponse> {
    const attestationResponse =
      credential.response as AuthenticatorAttestationResponse;

    const clientExtensionResults = credential.getClientExtensionResults();
    //@ts-ignore
    delete clientExtensionResults.prf;

    const extensionResults = credential.getClientExtensionResults();

    //@ts-ignore
    const prfKey = extensionResults.prf?.enabled
      ? btoa(
          String.fromCharCode.apply(
            null,
            Array.from(
              new Uint8Array(
                //@ts-ignore
                extensionResults.prf.results.first
              )
            )
          )
        )
      : null;

    if (!prfKey && isPRFSupportRequired) {
      throw new PasskeysAuthError(
        ErrorFrom.BrowserWebAuthn,
        ErrorCodes.PRFNotSupported,
        'Browser or Authenticator does not support PRF!'
      );
    }

    const response = await fetch(`${this.config.apiUrl}/register/complete`, {
      method: 'POST',
      headers: this.createHeaders(),
      body: JSON.stringify({
        session: session,
        response: {
          id: credential.id,
          rawId: this.arrayBufferToBase64Url(credential.rawId),
          type: credential.type,
          clientExtensionResults,
          response: {
            AttestationObject: this.arrayBufferToBase64Url(
              attestationResponse.attestationObject
            ),
            clientDataJson: this.arrayBufferToBase64Url(
              attestationResponse.clientDataJSON
            ),
          },
        },
        RPID: this.config.rpid,
        Origin: this.config.origin,
        nickname,
      }),
    });

    const res = await response.json();
    if (!response.ok) {
      throw new PasskeysAuthError(
        ErrorFrom.PasswordlessDev,
        ErrorCodes.HTTP,
        `${response.status} ${res.message}`
      );
    }

    return {
      ...res,
      prfKey,
    };
  }

  private arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
    const uint8Array = (() => {
      if (Array.isArray(buffer)) return Uint8Array.from(buffer);
      if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer);
      if (buffer instanceof Uint8Array) return buffer;

      const msg =
        'Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array';
      console.error(msg, buffer);
      throw new Error(msg);
    })();

    let string = '';
    for (let i = 0; i < uint8Array.byteLength; i++) {
      string += String.fromCharCode(uint8Array[i]);
    }

    const base64String = window.btoa(string);
    return this.base64ToBase64Url(base64String);
  }

  private base64ToBase64Url(base64: string): string {
    return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, '');
  }

  private async register(
    token: string,
    prf_salt: string,
    isPRFSupportRequired: boolean,
    nickname?: string
  ): Promise<Result<TokenPrfResponse> | void> {
    try {
      this.isBrowserSupported();
      this.handleAbort();

      const registration = await this.registerBegin(token);

      if (!registration.data) return;

      registration.data.challenge = this.base64UrlToArrayBuffer(
        registration.data.challenge
      );
      registration.data.user.id = this.base64UrlToArrayBuffer(
        registration.data.user.id
      );
      registration.data.excludeCredentials?.forEach(
        (cred: {id: string | BufferSource}) => {
          cred.id = this.base64UrlToArrayBuffer(cred.id);
        }
      );

      //@ts-ignore
      registration.data.extensions.prf = {eval: {}};
      //@ts-ignore
      registration.data.extensions.prf.eval.first = new TextEncoder().encode(
        prf_salt
      );

      const credential = (await navigator.credentials.create({
        publicKey: registration.data,
        signal: this.abortController.signal,
      })) as PublicKeyCredential;

      if (!credential) {
        throw new PasskeysAuthError(
          ErrorFrom.BrowserWebAuthn,
          ErrorCodes.FailedCreateCredential,
          'Failed to create credential (navigator.credentials.create returned null)'
        );
      }

      const result = await this.registerComplete(
        credential,
        registration.session,
        isPRFSupportRequired,
        nickname
      );

      return {
        token: result.token ?? '',
        prfKey: result.prfKey ?? '',
        passkeyCredentialId: credential.id,
        error: undefined,
      };

      // next steps
      // return a token from the API
      // Add a type to the token (method/action)
    } catch (caughtError: any) {
      if (caughtError instanceof PasskeysAuthError) {
        throw caughtError;
      }

      const errorMessage = this.getErrorMessage(caughtError);
      let errorCode;

      if (!caughtError) {
        errorCode = ErrorCodes.Unknown;
      } else if (caughtError.name === 'AbortError') {
        errorCode = ErrorCodes.AbortError;
      } else if (caughtError.name === 'NotAllowedError') {
        errorCode = ErrorCodes.NotAllowedError;
      } else if (caughtError.name === 'InvalidStateError') {
        errorCode = ErrorCodes.InvalidStateError;
      } else {
        errorCode = ErrorCodes.Unknown;
      }

      throw new PasskeysAuthError(
        ErrorFrom.BrowserWebAuthn,
        errorCode,
        errorMessage
      );
    }
  }

  private base64UrlToArrayBuffer(
    base64UrlString: string | BufferSource
  ): ArrayBuffer {
    // improvement: Remove BufferSource-type and add proper types upstream
    if (typeof base64UrlString !== 'string') {
      const msg =
        'Cannot convert from Base64Url to ArrayBuffer: Input was not of type string';
      console.error(msg, base64UrlString);
      throw new TypeError(msg);
    }

    const base64Unpadded = this.base64UrlToBase64(base64UrlString);
    const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4;
    const base64Padded = base64Unpadded.padEnd(
      base64Unpadded.length + paddingNeeded,
      '='
    );

    const binary = window.atob(base64Padded);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
      bytes[i] = binary.charCodeAt(i);
    }

    return bytes;
  }

  private base64UrlToBase64(base64Url: string): string {
    return base64Url.replace(/-/g, '+').replace(/_/g, '/');
  }

  private getErrorMessage(error: unknown) {
    return this.toErrorWithMessage(error).message;
  }

  private toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
    if (this.isErrorWithMessage(maybeError)) return maybeError;

    try {
      return new Error(JSON.stringify(maybeError));
    } catch {
      // fallback in case there's an error stringifying the maybeError
      // like with circular references for example.
      return new Error(String(maybeError));
    }
  }

  private isErrorWithMessage(error: unknown): error is ErrorWithMessage {
    return (
      typeof error === 'object' &&
      error !== null &&
      'message' in error &&
      typeof (error as Record<string, unknown>)['message'] === 'string'
    );
  }

  public async reAuthWithAlias(
    alias: string,
    isPRFSupportRequired = false,
    authToken?: string,
    backendData?: unknown
  ): PromiseResult<PasskeysReAuthResult> {
    try {
      const {token, prfKey, error, passkeyCredentialId} = await this.signin(
        {alias},
        isPRFSupportRequired
      );

      if (!token) throw error;

      const result = await this.verifyReAuth(token, authToken, backendData);

      if (!result) {
        throw new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.PassKeySignInVerificationFailed,
          'Passkey signin verification failed!'
        );
      }

      return {
        isPrfSupported: Boolean(prfKey),
        prfKey,
        verifyToken: result.verifyToken,
        passkeyCredentialId,
        error: undefined,
      };
    } catch (err: any) {
      if (err instanceof PasskeysAuthError) {
        return {error: err};
      } else {
        const error = new PasskeysAuthError(
          ErrorFrom.Unknown,
          ErrorCodes.Unknown,
          err?.message
        );
        return {error};
      }
    }
  }

  public async signinWithAlias(
    alias: string,
    isPRFSupportRequired = false
  ): PromiseResult<PasskeysAuthResult> {
    try {
      const {token, prfKey, error, passkeyCredentialId} = await this.signin(
        {alias},
        isPRFSupportRequired
      );

      if (!token) throw error;

      const result = await this.verifySignin(token);

      if (!result) {
        throw new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.PassKeySignInVerificationFailed,
          'Passkey signin verification failed!'
        );
      }

      return {
        isPrfSupported: Boolean(prfKey),
        prfKey,
        passkeyCredentialId,
        authToken: result.authToken,
        error: undefined,
        username: result.username,
      };
    } catch (err: any) {
      if (err instanceof PasskeysAuthError) {
        return {error: err};
      } else {
        const error = new PasskeysAuthError(
          ErrorFrom.Unknown,
          ErrorCodes.Unknown,
          err?.message
        );
        return {error};
      }
    }
  }
  public async signinWithAutofill(
    isPRFSupportRequired = false
  ): PromiseResult<PasskeysAuthResult> {
    try {
      if (!(await this.isAutofillSupported())) {
        throw new PasskeysAuthError(
          ErrorFrom.BrowserWebAuthn,
          ErrorCodes.Unknown,
          'Autofill authentication (conditional meditation) is not supported in this browser'
        );
      }
      const {token, prfKey, error, passkeyCredentialId} = await this.signin(
        {autofill: true},
        isPRFSupportRequired
      );

      if (!token) throw error;

      const result = await this.verifySignin(token);

      if (!result) {
        throw new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.PassKeySignInVerificationFailed,
          'Passkey signin verification failed!'
        );
      }

      return {
        isPrfSupported: Boolean(prfKey),
        passkeyCredentialId,
        prfKey,
        authToken: result.authToken,
        error: undefined,
        username: result.username,
      };
    } catch (err: any) {
      if (err instanceof PasskeysAuthError) {
        return {error: err};
      } else {
        const error = new PasskeysAuthError(
          ErrorFrom.Unknown,
          ErrorCodes.Unknown,
          err?.message
        );
        return {error};
      }
    }
  }

  private async isAutofillSupported(): Promise<boolean> {
    const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this
    if (!PublicKeyCredential.isConditionalMediationAvailable) return false;
    return PublicKeyCredential.isConditionalMediationAvailable() as Promise<boolean>;
  }

  private handleAbort() {
    this.abort();
    this.abortController = new AbortController();
  }

  private abort() {
    if (this.abortController) {
      this.abortController.abort();
    }
  }

  private async signinBegin(
    signinMethod: SigninMethod
  ): PromiseResult<SigninBeginResponse> {
    const response = await fetch(`${this.config.apiUrl}/signin/begin`, {
      method: 'POST',
      headers: this.createHeaders(),
      body: JSON.stringify({
        userId: 'userId' in signinMethod ? signinMethod.userId : undefined,
        alias: 'alias' in signinMethod ? signinMethod.alias : undefined,
        RPID: this.config.rpid,
        Origin: this.config.origin,
      }),
    });

    const res = await response.json();
    if (response.ok) {
      return res;
    }

    throw new PasskeysAuthError(
      ErrorFrom.PasswordlessDev,
      ErrorCodes.HTTP,
      `${response.status} ${res.message}`
    );
  }

  private async signinComplete(
    credential: PublicKeyCredential,
    session: string,
    isPRFSupportRequired: boolean
  ): PromiseResult<TokenPrfResponse> {
    const assertionResponse =
      credential.response as AuthenticatorAssertionResponse;

    const clientExtensionResults = credential.getClientExtensionResults();
    //@ts-ignore
    delete clientExtensionResults.prf;

    const extensionResults = credential.getClientExtensionResults();

    //@ts-ignore
    const prfKey = extensionResults.prf?.results?.first
      ? btoa(
          String.fromCharCode.apply(
            null,
            Array.from(
              new Uint8Array(
                //@ts-ignore
                extensionResults.prf.results.first
              )
            )
          )
        )
      : null;

    if (!prfKey && isPRFSupportRequired) {
      throw new PasskeysAuthError(
        ErrorFrom.BrowserWebAuthn,
        ErrorCodes.PRFNotSupported,
        'Browser or Authenticator does not support PRF!'
      );
    }

    const response = await fetch(`${this.config.apiUrl}/signin/complete`, {
      method: 'POST',
      headers: this.createHeaders(),
      body: JSON.stringify({
        session: session,
        response: {
          id: credential.id,
          rawId: this.arrayBufferToBase64Url(new Uint8Array(credential.rawId)),
          type: credential.type,
          clientExtensionResults,
          response: {
            authenticatorData: this.arrayBufferToBase64Url(
              assertionResponse.authenticatorData
            ),
            clientDataJson: this.arrayBufferToBase64Url(
              assertionResponse.clientDataJSON
            ),
            signature: this.arrayBufferToBase64Url(assertionResponse.signature),
          },
        },
        RPID: this.config.rpid,
        Origin: this.config.origin,
      }),
    });

    const res = await response.json();
    if (!response.ok) {
      throw new PasskeysAuthError(
        ErrorFrom.PasswordlessDev,
        ErrorCodes.HTTP,
        `${response.status} ${res.message}`
      );
    }

    return {
      ...res,
      //@ts-ignore
      prfKey,
    };
  }

  private async signin(
    signinMethod: SigninMethod,
    isPRFSupportRequired: boolean
  ): PromiseResult<TokenPrfResponse> {
    try {
      this.isBrowserSupported();
      this.handleAbort();

      // if signinMethod is undefined, set it to an empty object
      // this will cause a login using discoverable credentials
      if (!signinMethod) {
        signinMethod = {discoverable: true};
      }

      const signin = await this.signinBegin(signinMethod);

      if (!signin.data) throw signin.error;

      signin.data.challenge = this.base64UrlToArrayBuffer(
        signin.data.challenge
      );
      signin.data.allowCredentials?.forEach(
        (cred: {id: string | BufferSource}) => {
          cred.id = this.base64UrlToArrayBuffer(cred.id);
        }
      );
      signin.data.userVerification = 'required';

      //@ts-ignore
      signin.data.extensions = {prf: {eval: {}}};
      //@ts-ignore
      signin.data.extensions.prf.eval.first = new TextEncoder().encode(
        prf_salt
      );

      const credential = (await navigator.credentials.get({
        publicKey: signin.data,
        mediation:
          'autofill' in signinMethod
            ? ('conditional' as CredentialMediationRequirement)
            : undefined, // Typescript doesn't know about 'conditational' yet
        signal: this.abortController.signal,
      })) as PublicKeyCredential;

      const response = await this.signinComplete(
        credential,
        signin.session,
        isPRFSupportRequired
      );

      return {
        token: response.token ?? '',
        prfKey: response.prfKey ?? '',
        passkeyCredentialId: credential.id,
        error: undefined,
      };
    } catch (caughtError: any) {
      if (caughtError instanceof PasskeysAuthError) {
        throw caughtError;
      }

      const errorMessage = this.getErrorMessage(caughtError);
      let errorCode;

      if (!caughtError) {
        errorCode = ErrorCodes.Unknown;
      } else if (caughtError.name === 'AbortError') {
        errorCode = ErrorCodes.AbortError;
      } else if (caughtError.name === 'NotAllowedError') {
        errorCode = ErrorCodes.NotAllowedError;
      } else if (caughtError.name === 'InvalidStateError') {
        errorCode = ErrorCodes.InvalidStateError;
      } else {
        errorCode = ErrorCodes.Unknown;
      }

      throw new PasskeysAuthError(
        ErrorFrom.BrowserWebAuthn,
        errorCode,
        errorMessage
      );
    }
  }

  public async signup(
    name: string,
    email: string,
    isPRFSupportRequired = false,
    authtoken?: string,
    nickname?: string,
    backendData?: unknown
  ): PromiseResult<PasskeysRegisterResult> {
    const headers: any = {'Content-Type': 'application/json'};

    if (authtoken) {
      headers['Authorization'] = `Bearer ${authtoken}`;
    }

    try {
      let response = await fetch(
        `${this.config.backendApiRootUrl}/passkeys-auth/signup/begin`,
        {
          method: 'POST',
          headers,
          body: JSON.stringify(
            backendData ? {name, email, ...backendData} : {name, email}
          ),
        }
      );

      if (!response.ok) {
        throw new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.HTTP,
          `${response.status} ${(await response.json()).message}`
        );
      }

      const data: SignupBeginResponse = await response.json();
      const {token: registrationToken, userId} = data;

      const result = await this.register(
        registrationToken,
        prf_salt,
        isPRFSupportRequired,
        nickname
      );

      let prfKey;
      let passkeyCredentialId;
      if (result) {
        prfKey = result.prfKey;
        passkeyCredentialId = result.passkeyCredentialId;
      }

      response = await fetch(
        `${this.config.backendApiRootUrl}/passkeys-auth/signup/complete`,
        {
          method: 'POST',
          headers,
          body: JSON.stringify(
            backendData ? {userId, ...backendData} : {userId}
          ),
        }
      );

      if (!response.ok) {
        throw new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.HTTP,
          `${response.status} ${(await response.json()).message}`
        );
      }

      return {
        isPrfSupported: Boolean(prfKey),
        prfKey,
        passkeyCredentialId,
        error: undefined,
      };
    } catch (err: any) {
      return {error: err};
    }
  }

  private async verifySignin(token: string): Promise<VerifyAuthResponse> {
    const response = await fetch(
      `${this.config.backendApiRootUrl}/passkeys-auth/signin/verify`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({token}),
      }
    );

    if (!response.ok) {
      throw new PasskeysAuthError(
        ErrorFrom.Backend,
        ErrorCodes.HTTP,
        `${response.status} ${(await response.json()).message}`
      );
    }

    return (await response.json()) as VerifyAuthResponse;
  }

  private async verifyReAuth(
    token: string,
    authToken?: string,
    backendData?: unknown
  ): Promise<VerifyReAuthResponse> {
    const headers: any = {'Content-Type': 'application/json'};

    if (authToken) {
      headers['Authorization'] = `Bearer ${authToken}`;
    }

    let body = {token};

    if (backendData) {
      body = {token, ...backendData};
    }

    const response = await fetch(
      `${this.config.backendApiRootUrl}/passkeys-auth/re-auth/verify`,
      {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
      }
    );

    if (!response.ok) {
      throw new PasskeysAuthError(
        ErrorFrom.Backend,
        ErrorCodes.HTTP,
        `${response.status} ${(await response.json()).message}`
      );
    }

    return (await response.json()) as VerifyReAuthResponse;
  }

  public signupOrSigninAbort() {
    this.abort();
  }

  //Passkeys Credentials Management

  public async getUserPasskeyCredentials(
    authtoken?: string,
    userId?: string
  ): PromiseResult<GetUserPasskeyCredentialsResult> {
    if (!authtoken && !userId) {
      return {
        error: new PasskeysAuthError(
          ErrorFrom.Unknown,
          ErrorCodes.Unknown,
          'Please provide auth token or userId! '
        ),
      };
    }

    const headers: any = {'Content-Type': 'application/json'};

    if (authtoken) {
      headers.Authorization = `Bearer ${authtoken}`;
    }

    const response = await fetch(
      `${this.config.backendApiRootUrl}/passkeys-auth/credentials/list${userId ? '?userId=' + userId : ''}`,
      {
        method: 'GET',
        headers,
      }
    );

    const res: GetUserPasskeyCredentialsBackendResponse = await response.json();

    if (!response.ok) {
      return {
        error: new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.HTTP,
          `${response.status} ${res.message}`
        ),
      };
    }

    return {credentials: res.credentials, error: undefined};
  }

  public async deleteUserPasskeyCredential(
    credentialId: string,
    backendData?: unknown,
    authtoken?: string
  ): PromiseResult<DeleteUserPasskeyCredentialResult> {
    const headers: any = {'Content-Type': 'application/json'};

    if (authtoken) {
      headers['Authorization'] = `Bearer ${authtoken}`;
    }

    let body = {credentialId};

    if (backendData) {
      body = {credentialId, ...backendData};
    }

    const response = await fetch(
      `${this.config.backendApiRootUrl}/passkeys-auth/credentials/delete`,
      {
        method: 'DELETE',
        headers,
        body: JSON.stringify(body),
      }
    );

    if (!response.ok) {
      return {
        error: new PasskeysAuthError(
          ErrorFrom.Backend,
          ErrorCodes.HTTP,
          `${response.status} ${(await response.json()).message}`
        ),
      };
    }

    return {isDeletionSuccessful: true, error: undefined};
  }
}
