import FingerprintJS from '@fingerprintjs/fingerprintjs';
import {
  from,
  lastValueFrom,
  MonoTypeOperatorFunction,
  Observable,
  Observer,
  OperatorFunction,
  Subject,
  Subscriber,
  Subscription,
  throwError,
} from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
import {
  catchError,
  filter,
  finalize,
  map,
  mergeMap,
  retryWhen,
  share,
  takeUntil,
  tap,
  throwIfEmpty,
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';

import { AppEvents, DataQueryErrorType, deprecationWarning } from '@grafana/data';
import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, FetchResponse } from '@grafana/runtime';
import { appEvents } from 'app/core/app_events';
import { getConfig } from 'app/core/config';
import { getSessionExpiry, hasSessionExpiry } from 'app/core/utils/auth';
import { loadUrlToken } from 'app/core/utils/urlToken';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardSearchItem } from 'app/features/search/types';
import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
import { DashboardDTO } from 'app/types/dashboard';
import { FolderDTO } from 'app/types/folders';

import { ShowModalReactEvent } from '../../types/events';
import { isContentTypeJson, parseInitFromOptions, parseResponseBody, parseUrlFromOptions } from '../utils/fetch';
import { isDataQuery, isLocalUrl } from '../utils/query';

import { FetchQueue } from './FetchQueue';
import { FetchQueueWorker } from './FetchQueueWorker';
import { ResponseQueue } from './ResponseQueue';
import { ContextSrv, contextSrv } from './context_srv';

const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id';

export interface BackendSrvDependencies {
  fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>;
  appEvents: typeof appEvents;
  contextSrv: ContextSrv;
  logout: () => void;
}

export interface FolderRequestOptions {
  withAccessControl?: boolean;
}

const GRAFANA_TRACEID_HEADER = 'grafana-trace-id';

export interface InspectorStream {
  response: FetchResponse | FetchError;
  requestId?: string;
}

export class BackendSrv implements BackendService {
  private inFlightRequests: Subject<string> = new Subject<string>();
  private HTTP_REQUEST_CANCELED = -1;
  private noBackendCache: boolean;
  private inspectorStream: Subject<InspectorStream> = new Subject<InspectorStream>();
  private readonly fetchQueue: FetchQueue;
  private readonly responseQueue: ResponseQueue;
  private _tokenRotationInProgress?: Observable<FetchResponse> | null = null;
  private deviceID?: string | null = null;

  private dependencies: BackendSrvDependencies = {
    fromFetch: fromFetch,
    appEvents: appEvents,
    contextSrv: contextSrv,
    logout: () => {
      contextSrv.setLoggedOut();
    },
  };

  constructor(deps?: BackendSrvDependencies) {
    if (deps) {
      this.dependencies = {
        ...this.dependencies,
        ...deps,
      };
    }

    this.noBackendCache = false;
    this.internalFetch = this.internalFetch.bind(this);
    this.fetchQueue = new FetchQueue();
    this.responseQueue = new ResponseQueue(this.fetchQueue, this.internalFetch);

    this.initGrafanaDeviceID();

    new FetchQueueWorker(this.fetchQueue, this.responseQueue, getConfig());
  }

  private async initGrafanaDeviceID() {
    try {
      const fp = await FingerprintJS.load();
      const result = await fp.get();
      this.deviceID = result.visitorId;
    } catch (error) {
      console.error(error);
    }
  }

  async request<T = unknown>(options: BackendSrvRequest): Promise<T> {
    return await lastValueFrom(this.fetch<T>(options).pipe(map((response: FetchResponse<T>) => response.data)));
  }

  fetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>> {
    // We need to match an entry added to the queue stream with the entry that is eventually added to the response stream
    const id = uuidv4();
    const fetchQueue = this.fetchQueue;

    return new Observable((observer) => {
      // Subscription is an object that is returned whenever you subscribe to an Observable.
      // You can also use it as a container of many subscriptions and when it is unsubscribed all subscriptions within are also unsubscribed.
      const subscriptions: Subscription = new Subscription();

      // We're using the subscriptions.add function to add the subscription implicitly returned by this.responseQueue.getResponses<T>(id).subscribe below.
      subscriptions.add(
        this.responseQueue.getResponses<T>(id).subscribe((result) => {
          // The one liner below can seem magical if you're not accustomed to RxJs.
          // Firstly, we're subscribing to the result from the result.observable and we're passing in the outer observer object.
          // By passing the outer observer object then any updates on result.observable are passed through to any subscriber of the fetch<T> function.
          // Secondly, we're adding the subscription implicitly returned by result.observable.subscribe(observer).
          subscriptions.add(result.observable.subscribe(observer));
        })
      );

      // Let the fetchQueue know that this id needs to start data fetching.
      this.fetchQueue.add(id, options);

      // This returned function will be called whenever the returned Observable from the fetch<T> function is unsubscribed/errored/completed/canceled.
      return function unsubscribe() {
        // Change status to Done moved here from ResponseQueue because this unsubscribe was called before the responseQueue produced a result
        fetchQueue.setDone(id);

        // When subscriptions is unsubscribed all the implicitly added subscriptions above are also unsubscribed.
        subscriptions.unsubscribe();
      };
    });
  }

  chunkRequestId = 1;

  chunked(options: BackendSrvRequest): Observable<FetchResponse<Uint8Array | undefined>> {
    const requestId = options.requestId ?? `chunked-${this.chunkRequestId++}`;
    const controller = new AbortController();

    const init = parseInitFromOptions({
      ...options,
      requestId,
      abortSignal: controller.signal,
    });

    return new Observable((observer) => {
      // Calling fromFetch explicitly avoids the request queue
      const sub = parseUrlFromOptions(options)
        .pipe(mergeMap((url) => this.dependencies.fromFetch(url, init)))
        .subscribe(this.getChunkedResponseObserver({ controller, observer, options, requestId }));

      return function unsubscribe() {
        controller.abort('unsubscribe');
        sub.unsubscribe();
      };
    });
  }

  private getChunkedResponseObserver({
    controller,
    observer,
    options,
    requestId,
  }: {
    controller: AbortController;
    observer: Subscriber<FetchResponse<Uint8Array<ArrayBufferLike> | undefined>>;
    options: BackendSrvRequest;
    requestId: string;
  }): Partial<Observer<Response>> {
    let done = false;
    return {
      next: (response) => {
        const rsp = {
          status: response.status,
          statusText: response.statusText,
          ok: response.ok,
          headers: response.headers,
          url: response.url,
          type: response.type,
          redirected: response.redirected,
          config: options,
          traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
          data: undefined,
        };

        if (!response.body) {
          observer.next(rsp);
          observer.complete();
          return;
        }

        const reader = response.body.getReader();

        // Setup onabort callback so that we can cancel the reader properly
        controller.signal.onabort = () => {
          reader.cancel(controller.signal.reason);
        };

        async function process() {
          while (reader && !done) {
            const chunk = await reader.read();
            observer.next({
              ...rsp,
              data: chunk.value,
            });
            if (chunk.done) {
              done = true;
            }
          }
        }
        process()
          .then(() => {
            observer.complete();
          }) // runs in background
          .catch((e) => {
            console.log(requestId, 'catch', e);
            observer.error(e);
          }); // from abort
      },
      error: (e) => {
        observer.error(e);
      },
    };
  }

  private internalFetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>> {
    if (options.requestId) {
      this.inFlightRequests.next(options.requestId);
    }

    options = this.parseRequestOptions(options);

    const token = loadUrlToken();
    if (token !== null && token !== '') {
      if (config.jwtUrlLogin && config.jwtHeaderName) {
        options.headers = options.headers ?? {};
        options.headers[config.jwtHeaderName] = `${token}`;
      }
    }

    if (!!this.deviceID) {
      options.headers = options.headers ?? {};
      options.headers['X-Grafana-Device-Id'] = `${this.deviceID}`;
    }

    return parseUrlFromOptions(options).pipe(
      this.getFromFetchStream<T>(options),
      this.handleStreamResponse<T>(options),
      this.handleStreamError(options),
      this.handleStreamCancellation(options)
    );
  }

  resolveCancelerIfExists(requestId: string) {
    this.inFlightRequests.next(requestId);
  }

  cancelAllInFlightRequests() {
    this.inFlightRequests.next(CANCEL_ALL_REQUESTS_REQUEST_ID);
  }

  async datasourceRequest<T = unknown>(options: BackendSrvRequest) {
    return lastValueFrom(this.fetch<T>(options));
  }

  private parseRequestOptions(options: BackendSrvRequest): BackendSrvRequest {
    const orgId = this.dependencies.contextSrv.user?.orgId;

    // init retry counter
    options.retry = options.retry ?? 0;

    if (isLocalUrl(options.url)) {
      if (orgId) {
        options.headers = options.headers ?? {};
        options.headers['X-Grafana-Org-Id'] = orgId;
      }

      if (options.url.startsWith('/')) {
        options.url = options.url.substring(1);
      }

      if (options.headers?.Authorization) {
        options.headers['X-DS-Authorization'] = options.headers.Authorization;
        delete options.headers.Authorization;
      }

      if (this.noBackendCache) {
        options.headers = options.headers ?? {};
        options.headers['X-Grafana-NoCache'] = 'true';
      }
    }

    if (options.hideFromInspector === undefined) {
      // Hide all local non data query calls
      options.hideFromInspector = isLocalUrl(options.url) && !isDataQuery(options.url);
    }

    return options;
  }

  private getFromFetchStream<T>(options: BackendSrvRequest): OperatorFunction<string, FetchResponse<T>> {
    return (inputStream) =>
      inputStream.pipe(
        mergeMap((url) => {
          const init = parseInitFromOptions(options);

          return this.dependencies.fromFetch(url, init).pipe(
            mergeMap(async (response) => {
              const { status, statusText, ok, headers, url, type, redirected } = response;

              const responseType = options.responseType ?? (isContentTypeJson(headers) ? 'json' : undefined);

              const data = await parseResponseBody<T>(response, responseType);
              const fetchResponse: FetchResponse<T> = {
                status,
                statusText,
                ok,
                data,
                headers,
                url,
                type,
                redirected,
                config: options,
                traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
              };
              return fetchResponse;
            })
          );
        })
      );
  }

  showApplicationErrorAlert(err: FetchError) {}

  showSuccessAlert<T>(response: FetchResponse<T>) {
    const { config } = response;

    if (config.showSuccessAlert === false) {
      return;
    }

    // if showSuccessAlert is undefined we only show alerts non GET request, non data query and local api requests
    if (
      config.showSuccessAlert === undefined &&
      (config.method === 'GET' || isDataQuery(config.url) || !isLocalUrl(config.url))
    ) {
      return;
    }

    const data: { message: string } = response.data as any;

    if (data?.message) {
      this.dependencies.appEvents.emit(AppEvents.alertSuccess, [data.message]);
    }
  }

  showErrorAlert(config: BackendSrvRequest, err: FetchError) {
    // do not show non-user error alerts for api keys or render tokens, they are used for kiosk mode and reporting and can't react to error pop-ups
    if (
      (err.status < 400 || err.status >= 500) &&
      this.dependencies.contextSrv.isSignedIn &&
      (this.dependencies.contextSrv.user.authenticatedBy === 'apikey' ||
        this.dependencies.contextSrv.user.authenticatedBy === 'render')
    ) {
      return;
    }

    if (config.showErrorAlert === false) {
      return;
    }

    // is showErrorAlert is undefined we only show alerts non data query and local api requests
    if (config.showErrorAlert === undefined && (isDataQuery(config.url) || !isLocalUrl(config.url))) {
      return;
    }

    let description = '';
    let message = err.data.message;

    // Sometimes we have a better error message on err.message
    if (message === 'Unexpected error' && err.message) {
      message = err.message;
    }

    if (message.length > 80) {
      description = message;
      message = 'Error';
    }

    // Validation
    if (err.status === 422) {
      description = err.data.message;
      message = 'Validation failed';
    }

    this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [
      message,
      description,
      err.data.traceID,
    ]);
  }

  /**
   * Processes FetchError to ensure "data" property is an object.
   *
   * @see DataQueryError.data
   */
  processRequestError(options: BackendSrvRequest, err: FetchError): FetchError<{ message: string; error?: string }> {
    err.data = err.data ?? { message: 'Unexpected error' };

    if (typeof err.data === 'string') {
      err.data = {
        message: err.data,
        error: err.statusText,
        response: err.data,
      };
    }

    // If no message but got error string, copy to message prop
    if (err.data && !err.data.message && typeof err.data.error === 'string') {
      err.data.message = err.data.error;
    }

    // check if we should show an error alert
    if (err.data.message) {
      setTimeout(() => {
        if (!err.isHandled) {
          this.showErrorAlert(options, err);
        }
      }, 50);
    }

    this.inspectorStream.next({ response: err, requestId: options.requestId });
    return err;
  }

  private handleStreamResponse<T>(options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse<T>> {
    return (inputStream) =>
      inputStream.pipe(
        map((response) => {
          if (!response.ok) {
            const { status, statusText, data } = response;
            const fetchErrorResponse: FetchError = {
              status,
              statusText,
              data,
              config: options,
              traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
            };
            throw fetchErrorResponse;
          }
          return response;
        }),
        tap((response) => {
          this.showSuccessAlert(response);
          this.inspectorStream.next({ response: response, requestId: options.requestId });
        })
      );
  }

  private handleStreamError<T>(options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse<T>> {
    const { isSignedIn } = this.dependencies.contextSrv.user;

    return (inputStream) =>
      inputStream.pipe(
        retryWhen((attempts) =>
          attempts.pipe(
            mergeMap((error, i) => {
              const firstAttempt = i === 0 && options.retry === 0;

              if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) {
                if (error.data?.error?.id === 'ERR_TOKEN_REVOKED') {
                  this.dependencies.appEvents.publish(
                    new ShowModalReactEvent({
                      component: TokenRevokedModal,
                      props: {
                        maxConcurrentSessions: error.data?.error?.maxConcurrentSessions,
                      },
                    })
                  );
                  this.dependencies.contextSrv.setRedirectToUrl();
                  return throwError(() => error);
                }

                let authChecker = this.loginPing();
                if (hasSessionExpiry()) {
                  const expired = getSessionExpiry() * 1000 < Date.now();
                  if (expired) {
                    authChecker = this.rotateToken();
                  }
                }

                return from(authChecker).pipe(
                  catchError((err) => {
                    if (err.status === 401) {
                      this.dependencies.logout();
                      return throwError(err);
                    }
                    return throwError(err);
                  })
                );
              }

              return throwError(error);
            })
          )
        ),
        catchError((err: FetchError) => throwError(() => this.processRequestError(options, err)))
      );
  }

  private handleStreamCancellation(options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse> {
    return (inputStream) =>
      inputStream.pipe(
        takeUntil(
          this.inFlightRequests.pipe(
            filter((requestId) => {
              let cancelRequest = false;

              if (options && options.requestId && options.requestId === requestId) {
                // when a new requestId is started it will be published to inFlightRequests
                // if a previous long running request that hasn't finished yet has the same requestId
                // we need to cancel that request
                cancelRequest = true;
              }

              if (requestId === CANCEL_ALL_REQUESTS_REQUEST_ID) {
                cancelRequest = true;
              }

              return cancelRequest;
            })
          )
        ),
        // when a request is cancelled by takeUntil it will complete without emitting anything so we use throwIfEmpty to identify this case
        // in throwIfEmpty we'll then throw an cancelled error and then we'll return the correct result in the catchError or rethrow
        throwIfEmpty(() => ({
          type: DataQueryErrorType.Cancelled,
          cancelled: true,
          data: null,
          status: this.HTTP_REQUEST_CANCELED,
          statusText: 'Request was aborted',
          config: options,
        }))
      );
  }

  getInspectorStream(): Observable<InspectorStream> {
    return this.inspectorStream;
  }

  async get<T = any>(
    url: string,
    params?: BackendSrvRequest['params'],
    requestId?: BackendSrvRequest['requestId'],
    options?: Partial<BackendSrvRequest>
  ) {
    return this.request<T>({ ...options, method: 'GET', url, params, requestId });
  }

  async delete<T = unknown>(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
    return this.request<T>({ ...options, method: 'DELETE', url, data });
  }

  async post<T = any>(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
    return this.request<T>({ ...options, method: 'POST', url, data });
  }

  async patch<T = any>(url: string, data: unknown, options?: Partial<BackendSrvRequest>) {
    return this.request<T>({ ...options, method: 'PATCH', url, data });
  }

  async put<T = any>(url: string, data: unknown, options?: Partial<BackendSrvRequest>): Promise<T> {
    return this.request<T>({ ...options, method: 'PUT', url, data });
  }

  withNoBackendCache(callback: () => Promise<void>) {
    this.noBackendCache = true;
    return callback().finally(() => {
      this.noBackendCache = false;
    });
  }

  rotateToken() {
    if (this._tokenRotationInProgress) {
      return this._tokenRotationInProgress;
    }

    this._tokenRotationInProgress = this.fetch({ url: '/api/user/auth-tokens/rotate', method: 'POST', retry: 1 }).pipe(
      finalize(() => {
        this._tokenRotationInProgress = null;
      }),
      share()
    );

    return this._tokenRotationInProgress;
  }

  loginPing() {
    return this.fetch({ url: '/api/login/ping', method: 'GET', retry: 1 });
  }

  /** @deprecated */
  search(query: Parameters<typeof this.get>[1]): Promise<DashboardSearchItem[]> {
    return this.get('/api/search', query);
  }

  /** @deprecated */
  getDashboardByUid(uid: string): Promise<DashboardDTO> {
    // NOTE: When this is removed, we can also remove most instances of:
    // jest.mock('app/features/live/dashboard/dashboardWatcher
    deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)');
    return getDashboardAPI('v1').getDashboardDTO(uid);
  }

  getPublicDashboardByUid(uid: string) {
    return this.get<DashboardDTO>(`/api/public/dashboards/${uid}`);
  }

  /**
   * @deprecated Use getFolderByUidFacade from app/api/clients/folder/v1beta1/hooks instead
   * or manually handle calling legacy vs app platform API based on feature toggles
   */
  getFolderByUid(uid: string, options: FolderRequestOptions = {}) {
    deprecationWarning('backend_srv', 'getFolderByUid(uid)', 'getFolderByUidFacade(uid)');
    const queryParams = new URLSearchParams();
    if (options.withAccessControl) {
      queryParams.set('accesscontrol', 'true');
    }

    return this.get<FolderDTO>(`/api/folders/${uid}?${queryParams.toString()}`, undefined, undefined, {
      showErrorAlert: false,
    });
  }
}

// Used for testing and things that really need BackendSrv
export const backendSrv = new BackendSrv();
export const getBackendSrv = (): BackendSrv => backendSrv;
