/**
 * @license
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
/* Import to get Gerrit interface */
/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
import '../gr-error-dialog/gr-error-dialog';
import '../../shared/gr-alert/gr-alert';
import '../../shared/gr-overlay/gr-overlay';
import {getBaseUrl} from '../../../utils/url-util';
import {getAppContext} from '../../../services/app-context';
import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
import {GrAlert} from '../../shared/gr-alert/gr-alert';
import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
import {AccountId} from '../../../types/common';
import {
  EventType,
  NetworkErrorEvent,
  ServerErrorEvent,
  ShowAlertEventDetail,
  ShowErrorEvent,
} from '../../../types/events';
import {windowLocationReload} from '../../../utils/dom-util';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {fireIronAnnounce} from '../../../utils/event-util';
import {LitElement, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';

const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
const SIGN_IN_WIDTH_PX = 690;
const SIGN_IN_HEIGHT_PX = 500;
const TOO_MANY_FILES = 'too many files to find conflicts';
const AUTHENTICATION_REQUIRED = 'Authentication required\n';

// Bigger number has higher priority
const ErrorTypePriority = {
  [ErrorType.AUTH]: 3,
  [ErrorType.NETWORK]: 2,
  [ErrorType.GENERIC]: 1,
};

interface ErrorMsg {
  errorText?: string;
  status?: number;
  statusText?: string;
  url?: string;
  trace?: string | null;
  tip?: string;
}

export const __testOnly_ErrorType = ErrorType;

export function constructServerErrorMsg({
  errorText,
  status,
  statusText,
  url,
  trace,
  tip,
}: ErrorMsg) {
  let err = '';
  if (tip) {
    err += `${tip}\n\n`;
  }
  err += `Error ${status}`;
  if (statusText) {
    err += ` (${statusText})`;
  }
  if (errorText || url) {
    err += ': ';
  }
  if (errorText) {
    err += errorText;
  }
  if (url) {
    err += `\nEndpoint: ${url}`;
  }
  if (trace) {
    err += `\nTrace Id: ${trace}`;
  }
  return err;
}

@customElement('gr-error-manager')
export class GrErrorManager extends LitElement {
  /**
   * The ID of the account that was logged in when the app was launched. If
   * not set, then there was no account at launch.
   */
  @state() knownAccountId?: AccountId | null;

  @state() alertElement: GrAlert | null = null;

  @state() hideAlertHandle: number | null = null;

  @state() refreshingCredentials = false;

  @query('#noInteractionOverlay') noInteractionOverlay!: GrOverlay;

  @query('#errorDialog') errorDialog!: GrErrorDialog;

  @query('#errorOverlay') errorOverlay!: GrOverlay;

  /**
   * The time (in milliseconds) since the most recent credential check.
   */
  @state() lastCredentialCheck: number = Date.now();

  @property({type: String})
  loginUrl = '/login';

  private readonly reporting = getAppContext().reportingService;

  private readonly _authService = getAppContext().authService;

  private readonly eventEmitter = getAppContext().eventEmitter;

  private authErrorHandlerDeregistrationHook?: Function;

  private readonly restApiService = getAppContext().restApiService;

  private checkLoggedInTask?: DelayedTask;

  override connectedCallback() {
    super.connectedCallback();
    document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
    document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
    document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
    document.addEventListener('hide-alert', this.hideAlert);
    document.addEventListener('show-error', this.handleShowErrorDialog);
    document.addEventListener('visibilitychange', this.handleVisibilityChange);
    document.addEventListener('show-auth-required', this.handleAuthRequired);

    this.authErrorHandlerDeregistrationHook = this.eventEmitter.on(
      'auth-error',
      event => {
        this.handleAuthError(event.message, event.action);
      }
    );

    (
      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
    ).requestAvailability();
  }

  override disconnectedCallback() {
    this.clearHideAlertHandle();
    document.removeEventListener(
      EventType.SERVER_ERROR,
      this.handleServerError
    );
    document.removeEventListener(
      EventType.NETWORK_ERROR,
      this.handleNetworkError
    );
    document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
    document.removeEventListener('hide-alert', this.hideAlert);
    document.removeEventListener('show-error', this.handleShowErrorDialog);
    document.removeEventListener(
      'visibilitychange',
      this.handleVisibilityChange
    );
    document.removeEventListener('show-auth-required', this.handleAuthRequired);
    this.checkLoggedInTask?.cancel();

    if (this.authErrorHandlerDeregistrationHook) {
      this.authErrorHandlerDeregistrationHook();
    }
    super.disconnectedCallback();
  }

  override render() {
    return html`
      <gr-overlay with-backdrop="" id="errorOverlay">
        <gr-error-dialog
          id="errorDialog"
          @dismiss=${() => this.errorOverlay.close()}
          .loginUrl=${this.loginUrl}
        ></gr-error-dialog>
      </gr-overlay>
      <gr-overlay
        id="noInteractionOverlay"
        with-backdrop=""
        always-on-top=""
        no-cancel-on-esc-key=""
        no-cancel-on-outside-click=""
      >
      </gr-overlay>
    `;
  }

  private shouldSuppressError(msg: string) {
    return msg.includes(TOO_MANY_FILES);
  }

  private readonly handleAuthRequired = () => {
    this.showAuthErrorAlert(
      'Log in is required to perform that action.',
      'Log in.'
    );
  };

  private handleAuthError(msg: string, action: string) {
    this.noInteractionOverlay.open().then(() => {
      this.showAuthErrorAlert(msg, action);
    });
  }

  private readonly handleServerError = (e: ServerErrorEvent) => {
    const {request, response} = e.detail;
    response.text().then(errorText => {
      const url = request && (request.anonymizedUrl || request.url);
      const {status, statusText} = response;
      if (
        response.status === 403 &&
        !this._authService.isAuthed &&
        errorText === AUTHENTICATION_REQUIRED
      ) {
        // if not authed previously, this is trying to access auth required APIs
        // show auth required alert
        this.handleAuthRequired();
      } else if (
        response.status === 403 &&
        this._authService.isAuthed &&
        errorText === AUTHENTICATION_REQUIRED
      ) {
        // The app was logged at one point and is now getting auth errors.
        // This indicates the auth token may no longer valid.
        // Re-check on auth
        this._authService.clearCache();
        this.restApiService.getLoggedIn();
      } else if (!this.shouldSuppressError(errorText)) {
        const trace =
          response.headers && response.headers.get('X-Gerrit-Trace');
        if (response.status === 404) {
          this.showNotFoundMessageWithTip({
            status,
            statusText,
            errorText,
            url,
            trace,
          });
        } else if (response.status === 429) {
          this.showQuotaExceeded({status, statusText});
        } else {
          this.showErrorDialog(
            constructServerErrorMsg({
              status,
              statusText,
              errorText,
              url,
              trace,
            })
          );
        }
      }
      this.reporting.error('Server error', new Error(errorText));
    });
  };

  private showNotFoundMessageWithTip({
    status,
    statusText,
    errorText,
    url,
    trace,
  }: ErrorMsg) {
    this.restApiService.getLoggedIn().then(isLoggedIn => {
      const tip = isLoggedIn
        ? 'You might have not enough privileges.'
        : 'You might have not enough privileges. Sign in and try again.';
      this.showErrorDialog(
        constructServerErrorMsg({
          status,
          statusText,
          errorText,
          url,
          trace,
          tip,
        }),
        {
          showSignInButton: !isLoggedIn,
        }
      );
    });
  }

  private showQuotaExceeded({status, statusText}: ErrorMsg) {
    const tip = 'Try again later';
    const errorText = 'Too many requests from this client';
    this.showErrorDialog(
      constructServerErrorMsg({
        status,
        statusText,
        errorText,
        tip,
      })
    );
  }

  private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => {
    this._showAlert(
      e.detail.message,
      e.detail.action,
      e.detail.callback,
      e.detail.dismissOnNavigation,
      undefined,
      e.detail.showDismiss
    );
  };

  private readonly handleNetworkError = (e: NetworkErrorEvent) => {
    this._showAlert('Server unavailable');
    this.reporting.error('Network error', new Error(e.detail.error.message));
  };

  // TODO(dhruvsri): allow less priority alerts to override high priority alerts
  // In some use cases we may want generic alerts to show along/over errors
  // private but used in tests
  canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
    return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
  }

  _showAlert(
    text: string,
    actionText?: string,
    actionCallback?: () => void,
    dismissOnNavigation?: boolean,
    type?: ErrorType,
    showDismiss?: boolean
  ) {
    if (this.alertElement) {
      // check priority before hiding
      if (!this.canOverride(type, this.alertElement.type)) return;
      this.hideAlert();
    }

    this.clearHideAlertHandle();
    if (dismissOnNavigation) {
      // Persist alert until navigation.
      document.addEventListener('location-change', this.hideAlert);
    } else {
      this.hideAlertHandle = window.setTimeout(
        this.hideAlert,
        HIDE_ALERT_TIMEOUT_MS
      );
    }
    const el = this.createToastAlert(showDismiss);
    el.show(text, actionText, actionCallback);
    this.alertElement = el;
    fireIronAnnounce(this, `Alert: ${text}`);
    this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
  }

  private readonly hideAlert = () => {
    if (!this.alertElement) {
      return;
    }

    this.alertElement.hide();
    this.alertElement = null;

    // Remove listener for page navigation, if it exists.
    document.removeEventListener('location-change', this.hideAlert);
  };

  private clearHideAlertHandle() {
    if (this.hideAlertHandle !== null) {
      window.clearTimeout(this.hideAlertHandle);
      this.hideAlertHandle = null;
    }
  }

  // private but used in tests
  showAuthErrorAlert(errorText: string, actionText?: string) {
    // hide any existing alert like `reload`
    // as auth error should have the highest priority
    if (this.alertElement) {
      this.alertElement.hide();
    }

    this.alertElement = this.createToastAlert();
    this.alertElement.type = ErrorType.AUTH;
    this.alertElement.show(errorText, actionText, () =>
      this.createLoginPopup()
    );
    fireIronAnnounce(this, errorText);
    this.reporting.reportInteraction('show-auth-error', {text: errorText});
    this.refreshingCredentials = true;
    this.requestCheckLoggedIn();
    if (!document.hidden) {
      this.handleVisibilityChange();
    }
  }

  // private but used in tests
  createToastAlert(showDismiss?: boolean) {
    const el = document.createElement('gr-alert');
    el.owner = this;
    el.toast = true;
    el.showDismiss = !!showDismiss;
    return el;
  }

  private readonly handleVisibilityChange = () => {
    // Ignore when the page is transitioning to hidden (or hidden is undefined).
    if (document.hidden !== false) return;

    // If not currently refreshing credentials and the credentials are old,
    // request them to confirm their validity or (display an auth toast if it
    // fails).
    const timeSinceLastCheck = Date.now() - this.lastCredentialCheck;
    if (
      !this.refreshingCredentials &&
      this.knownAccountId !== undefined &&
      timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
    ) {
      this.reporting.reportInteraction('visibility-sign-in-check');
      this.lastCredentialCheck = Date.now();

      // check auth status in case:
      // - user signed out
      // - user switched account
      this.checkSignedIn();
    }
  };

  // private but used in tests
  requestCheckLoggedIn() {
    this.checkLoggedInTask = debounce(
      this.checkLoggedInTask,
      () => this.checkSignedIn(),
      CHECK_SIGN_IN_INTERVAL_MS
    );
  }

  // private but used in tests
  checkSignedIn() {
    this.lastCredentialCheck = Date.now();

    // force to refetch account info
    this.restApiService.invalidateAccountsCache();
    this._authService.clearCache();

    this.restApiService.getLoggedIn().then(isLoggedIn => {
      if (!this.refreshingCredentials) return;

      if (!isLoggedIn) {
        // check later
        // 1. guest mode
        // 2. or signed out
        // in case #2, auth-error is taken care of separately
        this.requestCheckLoggedIn();
      } else {
        this.restApiService.getAccount().then(account => {
          if (this.refreshingCredentials) {
            // If the credentials were refreshed but the account is different,
            // then reload the page completely.
            if (account?._account_id !== this.knownAccountId) {
              this.reporting.reportInteraction('sign-in-window-reload', {
                oldAccount: !!this.knownAccountId,
                newAccount: !!account?._account_id,
              });
              this.reloadPage();
              return;
            }

            this.handleCredentialRefreshed();
          }
        });
      }
    });
  }

  reloadPage() {
    windowLocationReload();
  }

  private createLoginPopup() {
    const left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
    const top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
    const options = [
      `width=${SIGN_IN_WIDTH_PX}`,
      `height=${SIGN_IN_HEIGHT_PX}`,
      `left=${left}`,
      `top=${top}`,
    ];
    window.open(
      getBaseUrl() + '/login/%3FcloseAfterLogin',
      '_blank',
      options.join(',')
    );
    window.addEventListener('focus', this.handleWindowFocus);
  }

  // private but used in tests
  handleCredentialRefreshed() {
    window.removeEventListener('focus', this.handleWindowFocus);
    this.refreshingCredentials = false;
    this.hideAlert();
    this._showAlert('Credentials refreshed.');
    this.noInteractionOverlay.close();

    // Clear the cache for auth
    this._authService.clearCache();
  }

  private readonly handleWindowFocus = () => {
    this.checkLoggedInTask?.flush();
  };

  private readonly handleShowErrorDialog = (e: ShowErrorEvent) => {
    this.showErrorDialog(e.detail.message);
  };

  // private but used in tests
  showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
    this.reporting.reportErrorDialog(message);
    this.errorDialog.text = message;
    this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
    this.errorOverlay.open();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-error-manager': GrErrorManager;
  }
}
