import { logger, startSpan } from '@sentry/react';
import { type AxiosError } from 'axios';
import { minutesToMilliseconds } from 'date-fns';
import { jwtDecode } from 'jwt-decode';

import { type SnackbarProps } from '@material-hu/components/design-system/Snackbar/types';

import { encryptStorage } from 'src/config/encrypt-storage';
import { authRefresh } from 'src/services/authService';
import { type TokenPayload } from 'src/types/login';
import { type LokaliseTFunction } from 'src/utils/i18n';
import { getRetryTimeout } from 'src/utils/retry';
import wait from 'src/utils/wait';

import { TokenManagerError, TokenManagerStatus } from './types';

const REFRESH_TOKEN_EXPIRATION_TIME = minutesToMilliseconds(5); // 5 minutes

/**
 * TokenManager is a singleton that manages the tokens for the app
 * It is used to get the access token and refresh token from the storage
 * It is also used to refresh the access token
 * It is used to check if the token is valid
 */
class TokenManager {
  /**
   * The access token
   */
  private accessToken: string | null = null;

  /**
   * The refresh token
   */
  private refreshToken: string | null = null;

  /**
   * Per-session refresh promises, keyed by sessionId.
   *
   * Using a Map instead of a single `refreshingPromise` ensures complete isolation
   * between sessions: when a zombie loop from a previous session finishes, it deletes
   * its own key and never touches the new session's promise. This also eliminates the
   * need to clear the promise in `removeTokens()`, removing a class of race conditions
   * where the zombie IIFE's final cleanup would clobber the new session's active promise.
   */
  private refreshingPromises: Map<string, Promise<void>> = new Map();

  /**
   * The session ID of the current session, decoded from the refresh token (or access token).
   * Used to detect session changes mid-loop: each refresh loop captures this value at start,
   * and breaks immediately if the singleton's sessionId has changed (e.g. logout + re-login).
   */
  private sessionId: string | null = null;

  /**
   * The status of the token manager
   */
  private status: TokenManagerStatus = TokenManagerStatus.UNINITIALIZED;

  private error: TokenManagerError | null = null;

  /**
   * Callback function to show snackbar when refresh fails and accessToken is not valid
   */
  private onRefreshError?: (props: SnackbarProps) => void;

  /**
   * Lokalise translation function for token_manager namespace
   */
  private t: LokaliseTFunction | null = null;

  /**
   * Flag to track if error snackbar was shown (to show success only if error was shown)
   */
  private errorSnackbarShown = false;

  constructor() {
    this.initializeTokens();
  }

  /**
   * Set the status of the token manager. It is also used to update the store
   * @param status - The status to set
   */
  private setStatus = (
    status: TokenManagerStatus,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _error: TokenManagerError | null = null,
  ) => {
    this.status = status;
    this.error = _error;
  };

  /**
   * Initialize tokens from encrypted storage
   */
  private initializeTokens = () => {
    const accessToken = encryptStorage.getItem('accessToken');
    const refreshToken = encryptStorage.getItem('refreshToken');

    if (!accessToken || !refreshToken) {
      this.setStatus(
        TokenManagerStatus.UNAUTHENTICATED,
        TokenManagerError.TOKENS_NOT_FOUND,
      );
      return;
    }

    this.setTokens(accessToken, refreshToken);
    this.setStatus(TokenManagerStatus.AUTHENTICATED);
  };

  /**
   * Returns the current access and refresh tokens from memory.
   * Kept with the same name as the old util for easy migration.
   */
  public getCorrectToken = (): {
    accessToken: string | null;
    refreshToken: string | null;
  } => ({
    accessToken: this.accessToken,
    refreshToken: this.refreshToken,
  });

  public setTokens = (accessToken: string, refreshToken: string) => {
    try {
      const { sessionId } = jwtDecode<TokenPayload>(
        refreshToken || accessToken,
      );

      encryptStorage.setItem('sessionId', sessionId);
      encryptStorage.setItem('accessToken', accessToken);
      encryptStorage.setItem('refreshToken', refreshToken);

      // In-memory
      this.sessionId = sessionId;
      this.accessToken = accessToken;
      this.refreshToken = refreshToken;
    } catch (error) {
      logger.error(
        'JWT decode failed on setTokens — tokens removed, reloading',
        {
          message: 'JWT decode failed on setTokens — tokens removed, reloading',
          module: 'Web',
          component: 'TokenManager',
          decodedFrom: refreshToken ? 'refreshToken' : 'accessToken',
          error: error instanceof Error ? error.message : String(error),
        },
      );
      this.removeTokens();
      window.location.reload();
    }
  };

  public removeTokens = () => {
    this.accessToken = null;
    this.refreshToken = null;
    // Clearing sessionId is the signal for any running refresh loop to break:
    // each loop captures its own loopSessionId at start and checks it on every
    // iteration — a mismatch means the session changed and the loop exits.
    // No need to touch `refreshingPromises`: each zombie IIFE cleans up its
    // own key (by loopSessionId) when it finishes, so it can never clobber
    // a new session's entry.
    this.sessionId = null;
    encryptStorage.removeItem('accessToken');
    encryptStorage.removeItem('refreshToken');
    encryptStorage.removeItem('sessionId');
    this.errorSnackbarShown = false;
    this.setStatus(TokenManagerStatus.UNAUTHENTICATED);
  };

  /**
   * Calls the refresh endpoint (`/auth/refresh`).
   *
   * Wrapped in a Sentry span so the refresh is recorded as a child of the
   * currently active transaction. The span is only sent to Sentry when its
   * parent transaction passes `beforeSendTransaction` (see `src/config/sentry.ts`).
   */
  private callRefreshService = async (retryAttempt: number = 0) =>
    startSpan(
      {
        name: 'auth.refresh_token',
        op: 'auth.refresh',
        attributes: {
          'token_manager.retry_attempt': retryAttempt,
        },
      },
      async span => {
        const response = await authRefresh();
        span.setAttribute('http.response.status_code', response.status);
        return response;
      },
    );

  private startRefreshTokenLoop = async (
    loopSessionId: string | null,
    mode: 'background' | 'blocking',
  ) => {
    this.setStatus(TokenManagerStatus.REFRESHING);
    // Local to this loop run — no shared class state, so concurrent sessions
    // (e.g. a zombie loop from a previous session) never interfere with each other.
    let retryAttempt = 0;

    /**
     * Start a loop that will retry to refresh the token until it succeeds or the refresh token is no longer valid
     */
    // eslint-disable-next-line no-constant-condition
    while (true) {
      try {
        // Guard against zombie loops: if the session changed (logout, re-login),
        // this loop no longer owns the current session — exit immediately.
        if (this.sessionId !== loopSessionId) {
          logger.info(
            'Refresh loop aborted — session changed since loop started',
            {
              message:
                'Refresh loop aborted — session changed since loop started',
              module: 'Web',
              component: 'TokenManager',
            },
          );
          break;
        }

        const currentRefreshToken = this.refreshToken;
        if (!currentRefreshToken) {
          this.setStatus(
            TokenManagerStatus.UNAUTHENTICATED,
            TokenManagerError.TOKENS_NOT_FOUND,
          );
          logger.error(
            'Refresh loop aborted — refresh token not found in memory',
            {
              message:
                'Refresh loop aborted — refresh token not found in memory',
              module: 'Web',
              component: 'TokenManager',
              error: this.error,
            },
          );
          break;
        }

        if (!this.isRefreshTokenValid()) {
          this.setStatus(
            TokenManagerStatus.UNAUTHENTICATED,
            TokenManagerError.REFRESH_TOKEN_EXPIRED,
          );
          logger.error('Refresh loop aborted — refresh token is expired', {
            message: 'Refresh loop aborted — refresh token is expired',
            module: 'Web',
            component: 'TokenManager',
            error: this.error,
          });
          break;
        }

        // Try to refresh the token
        const response = await this.callRefreshService(retryAttempt);

        // Guard: check session hasn't changed while the network call was in flight.
        // The top-of-loop check only covers the start of each retry; here we re-check
        // right after the await so no branch (success or error) can mutate state for
        // the wrong session.
        if (this.sessionId !== loopSessionId) {
          logger.info(
            'Refresh loop discarded — session changed while request was in flight',
            {
              message:
                'Refresh loop discarded — session changed while request was in flight',
              module: 'Web',
              component: 'TokenManager',
            },
          );
          break;
        }

        if (response.status === 200 && response.data) {
          // Success: update tokens and exit retry loop
          const { accessToken, refreshToken } = response.data;
          this.setTokens(accessToken, refreshToken);
          this.setStatus(TokenManagerStatus.AUTHENTICATED);
          logger.info('Token refresh succeeded — new tokens set', {
            message: 'Token refresh succeeded — new tokens set',
            module: 'Web',
            component: 'TokenManager',
            succeededOnAttempt: retryAttempt,
          });
          // Show success snackbar only if error was shown before, ONLY ONCE
          if (this.errorSnackbarShown && this.onRefreshError) {
            this.onRefreshError({
              title: this.t?.('refresh_success'),
              variant: 'success',
            });
            this.errorSnackbarShown = false; // Reset for next time
          }
          break;
        } else if (response.status === 401 || response.status === 403) {
          // NOTE: This branch IS reachable. authRefresh uses validateStatus: null,
          // which tells Axios to resolve all responses regardless of HTTP status.
          // A 401/403 here means the refresh token is definitively invalid — no point retrying.
          this.setStatus(
            TokenManagerStatus.UNAUTHENTICATED,
            TokenManagerError.REFRESH_TOKEN_NOT_VALID,
          );
          logger.error('Refresh loop aborted — server rejected refresh token', {
            message: 'Refresh loop aborted — server rejected refresh token',
            module: 'Web',
            component: 'TokenManager',
            mode,
            httpStatus: response.status,
          });
          break;
        }

        /**
         * Unexpected HTTP status (e.g. 500, 503). Since authRefresh uses validateStatus: null,
         * all responses resolve normally. We tag the error with httpStatus so the catch block
         * can classify it as errorType: 'http' without faking an AxiosError shape.
         */
        throw Object.assign(
          new Error(
            `Refresh request returned unexpected status: ${response.status}`,
          ),
          { httpStatus: response.status },
        );
      } catch (error) {
        const httpStatus =
          (error as { httpStatus?: number }).httpStatus ??
          (error as AxiosError).response?.status;
        logger.error('Refresh attempt failed — will retry with backoff', {
          message: 'Refresh attempt failed — will retry with backoff',
          module: 'Web',
          component: 'TokenManager',
          retryAttempt,
          errorType: httpStatus != null ? 'http' : 'network',
          httpStatus,
          accessTokenValid: this.isTokenValid(),
          accessTokenRemainingSeconds: Math.round(
            this.getTokenRemainingMs(this.accessToken) / 1000,
          ),
          refreshTokenRemainingSeconds: Math.round(
            this.getTokenRemainingMs(this.refreshToken) / 1000,
          ),
          error: error instanceof Error ? error.message : String(error),
        });
      }

      // Show error snackbar if accessToken is not valid, ONLY ONCE
      if (
        !this.isTokenValid() &&
        !this.errorSnackbarShown &&
        this.onRefreshError
      ) {
        this.onRefreshError({
          title: this.t?.('errors.refresh_error'),
          variant: 'warning',
        });
        this.errorSnackbarShown = true;
      }

      // Wait for incremental RETRY_TIMEOUT before next attempt
      const timeout = getRetryTimeout(retryAttempt);
      if (timeout > 0) {
        await wait(timeout);
      }
      // Increment retry attempt for next iteration
      retryAttempt++;
    }
  };

  /**
   * Refresh the access token.
   *
   * Uses a per-session Map so that each session's deduplication is fully isolated.
   * When a zombie IIFE from a previous session finishes, it deletes its own key
   * (by loopSessionId) and never touches the new session's entry.
   */
  private refreshAccessToken = async (
    mode: 'background' | 'blocking' = 'blocking',
  ) => {
    // Snapshot sessionId before any await — it's the key for this session's promise.
    const currentSessionId = this.sessionId;

    if (currentSessionId) {
      const existing = this.refreshingPromises.get(currentSessionId);
      if (existing) return existing;
    }

    const promise = (async () => {
      logger.info('Token refresh started', {
        message: 'Token refresh started',
        module: 'Web',
        component: 'TokenManager',
        mode,
        accessTokenRemainingSeconds: Math.round(
          this.getTokenRemainingMs(this.accessToken) / 1000,
        ),
      });
      await this.startRefreshTokenLoop(currentSessionId, mode);
      // Clean up only this session's key — never clobbers a new session's entry.
      if (currentSessionId) this.refreshingPromises.delete(currentSessionId);

      if (this.status === TokenManagerStatus.UNAUTHENTICATED) {
        // Refresh failed because refresh token is no longer valid (expired or rejected).
        throw new Error(TokenManagerError.REFRESH_TOKEN_FAILED);
      }
    })();

    if (currentSessionId)
      this.refreshingPromises.set(currentSessionId, promise);
    return promise;
  };

  /**
   * Returns an access token to be used in requests, handling refresh behavior
   *
   * @returns Access token string or `null` when unauthenticated / no usable token.
   */
  public getValidAccessToken = async (): Promise<string | null> => {
    /**
     * If there are no tokens yet (public routes like login), do not attempt a refresh.
     * The API interceptor expects `null` in this case (no Authorization header).
     */
    if (!this.accessToken || !this.refreshToken) {
      return null;
    }

    const startRefresh =
      this.getTokenRemainingMs(this.accessToken) <
      REFRESH_TOKEN_EXPIRATION_TIME;

    if (this.isTokenValid() && !startRefresh) {
      return this.accessToken;
    }

    /**
     * If access token is still usable (not expired), do not block the request.
     * Start the refresh loop in the background. If the backend is down, we'll keep retrying.
     *
     * IMPORTANT: Only start refresh if token has less than 5 minutes remaining.
     * This prevents unnecessary refreshes immediately after login (when token is fresh).
     */
    if (this.isTokenValid() && startRefresh) {
      // Start refresh in background, but return current token immediately
      void this.refreshAccessToken('background').catch(() => {
        // Intentionally ignored: requests can continue while the current access token is still valid.
      });
      return this.accessToken;
    }

    /**
     * If access token is expired, we MUST block and wait for refresh loop to succeed
     * (or fail due to refresh token invalid), as requested.
     */
    try {
      await this.refreshAccessToken('blocking');
      return this.accessToken;
    } catch {
      // Refresh failed, accessToken is not valid
      // The snackbar is already shown in refreshAccessToken if handler is set
      return null;
    }
  };

  /**
   * Remaining time until a JWT expiration (ms). Returns 0 when token is missing/invalid/expired.
   * Defensive: jwtDecode can throw on malformed tokens; treat them as expired (0 ms remaining).
   */
  private getTokenRemainingMs = (token: string | null): number => {
    if (!token) return 0;
    try {
      const tokenExpMs = (jwtDecode(token).exp || 0) * 1000;
      const remaining = tokenExpMs - Date.now();
      return Number.isFinite(remaining) ? Math.max(0, remaining) : 0;
    } catch {
      return 0;
    }
  };

  /**
   * Check if the access token is valid (exists and not expired)
   */
  public isTokenValid = () => {
    if (!this.accessToken) {
      return false;
    }
    return this.getTokenRemainingMs(this.accessToken) > 0;
  };

  /**
   * Check if the refresh token is valid
   * @returns True if the refresh token is valid, false otherwise
   */
  private isRefreshTokenValid = (): boolean => {
    if (!this.refreshToken) {
      return false;
    }
    return this.getTokenRemainingMs(this.refreshToken) > 0;
  };

  /**
   * Set the callback function to show snackbar when refresh fails
   * @param handler - Function to show snackbar with SnackbarProps
   */
  public setRefreshErrorHandler = (handler: (props: SnackbarProps) => void) => {
    this.onRefreshError = handler;
  };

  /**
   * Set the Lokalise translation function
   * @param t - Lokalise translation function
   */
  public setLokaliseTFunction = (t: (key: string) => string) => {
    this.t = t;
  };
}

export const tokenManager = new TokenManager();
