import { inject } from '@angular/core';
import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client/core';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { SetContextLink } from '@apollo/client/link/context';
import { ErrorLink } from '@apollo/client/link/error';
import { TranslocoService } from '@jsverse/transloco';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — apollo-upload-client@19 ships ESM modules without bundled types
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
import { environment } from 'environments/environment';
import { EMPTY, from, switchMap } from 'rxjs';
import { isTokenExpired } from '../authentication/shared/authentication';
import { TokenStore } from '../authentication/shared/token-store.service';
import { extractGraphqlErrorTranslations } from './graphql';
import type {
  GraphQLAuthHandler,
  GraphQLErrorHandler,
  GraphQLOriginalError,
} from './graphql.types';
import { BASIC_AUTH_OPERATIONS } from './handlers/aurora-graphql-auth.handler';
import { GRAPHQL_AUTH_HANDLER, GRAPHQL_ERROR_HANDLER } from './handlers/graphql.tokens';

// ---------------------------------------------------------------------------
// createAuroraGraphqlLinks — pure factory, injectable or testable
// ---------------------------------------------------------------------------

/**
 * Dependencies accepted by createAuroraGraphqlLinks.
 * Splitting them out allows unit tests to pass a terminating mock link
 * instead of a real UploadHttpLink that would require a network and a URI.
 */
export interface AuroraGraphqlLinksDeps {
  translocoService: TranslocoService;
  errorHandler: GraphQLErrorHandler;
  authHandler: GraphQLAuthHandler;
  tokenStore: Pick<TokenStore, 'accessToken' | 'refreshToken'>;
  /** The terminating link. Production code passes a real UploadHttpLink. */
  httpLink: ApolloLink;
}

/**
 * Builds and returns the composed Apollo link chain.
 *
 * Chain order: [errorLink, headersLink, httpLink]
 *
 * IMPORTANT — why errorLink comes FIRST:
 * In Apollo, `forward(operation)` inside a link only re-executes links
 * DOWNSTREAM of it (i.e., links that appear AFTER it in the array).
 * `headersLink` (SetContextLink) reads `tokenStore.accessToken()` and attaches
 * the Bearer token.  If headersLink were placed BEFORE errorLink, the retry
 * `forward(operation)` call inside errorLink would skip headersLink and send
 * the request without the fresh token, causing an immediate second 401 and
 * routing to `handleUnauthorized()` (sign-out).
 *
 * By placing errorLink first, `forward(operation)` on retry re-runs headersLink,
 * which reads the now-fresh token written by `authHandler.tryRefresh()`, and the
 * retried request succeeds.
 */
export const createAuroraGraphqlLinks = ({
  translocoService,
  errorHandler,
  authHandler,
  tokenStore,
  httpLink,
}: AuroraGraphqlLinksDeps): ApolloLink => {
  const headersLink = new SetContextLink(async (prevContext, operation) => {
    const headers: Record<string, string> = {};

    // merge any per-operation context headers.
    const previousHeaders = (prevContext as { headers?: unknown }).headers;
    if (previousHeaders && typeof previousHeaders === 'object') {
      Object.assign(headers, previousHeaders as Record<string, string>);
    }

    // timezone
    headers['X-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone;

    const handlerHeader = await authHandler.getAuthorizationHeader(operation.operationName ?? null);
    if (handlerHeader) {
      headers['Authorization'] = handlerHeader;
    } else {
      const token = tokenStore.accessToken();
      if (token && !isTokenExpired(token)) {
        headers['Authorization'] = `Bearer ${token}`;
      }
    }

    return { headers };
  });

  const errorLink = new ErrorLink(({ error, operation, forward }) => {
    if (CombinedGraphQLErrors.is(error)) {
      // Classify the auth failures we care about by their backend originalError.
      const hasOriginal = (statusCode: number, prefix: string): boolean =>
        error.errors.some((graphqlError) => {
          const ext = graphqlError.extensions as Record<string, unknown> | undefined;
          const original = ext?.['originalError'] as GraphQLOriginalError | undefined;
          if (!original) return false;

          const msg = typeof original.message === 'string' ? original.message : '';
          return original.statusCode === statusCode && msg.startsWith(prefix);
        });

      // Access token expired (Bearer no longer accepted) — recoverable via refresh.
      const isAccessExpiry = hasOriginal(401, 'OAuthAccessToken not allowed');
      // Refresh token missing/invalid server-side — not recoverable, sign out.
      const isRefreshInvalid = hasOriginal(404, 'OAuthRefreshToken');

      if (isAccessExpiry) {
        const operationName = operation.operationName ?? null;
        const context = operation.getContext() as Record<string, unknown>;

        // Scope guard (D6): only user/Bearer ops with a refresh token may refresh; exclude the
        // client-Basic ops so the refresh grant itself cannot re-enter this path. Retry-once
        // guard (D5): an already-retried op that 401s again must fall through, not loop.
        const isBasicOperation =
          operationName !== null && BASIC_AUTH_OPERATIONS.includes(operationName);
        const alreadyRetried = context['__refreshRetried'] === true;

        if (isBasicOperation || alreadyRetried || !tokenStore.refreshToken()) {
          authHandler.handleUnauthorized();
          return;
        }

        // Mark before retrying so a second 401 on the retried op falls through to sign-out.
        operation.setContext({ __refreshRetried: true });

        // D3: refresh, then retry via forward() on success — the retried pass re-runs
        // SetContextLink (headersLink), which now reads the fresh access token from
        // tokenStore written by tryRefresh(). Sign out on failure.
        return from(authHandler.tryRefresh()).pipe(
          switchMap((refreshed) => {
            if (refreshed) return forward(operation);
            authHandler.handleUnauthorized();
            return EMPTY;
          }),
        );
      }

      if (isRefreshInvalid) {
        authHandler.handleUnauthorized();
        return;
      }

      // Per-operation opt-in: callers may set context.skipGlobalErrors to suppress specific
      // RFC 6749 error codes from the global toast (e.g. 'invalid_grant' for inline display).
      // skipCodes is passed directly into extractGraphqlErrorTranslations so errors are filtered
      // BEFORE translation — no index-drift possible in multi-error responses.
      const ctx = operation.getContext() as Record<string, unknown>;
      const skipCodes = Array.isArray(ctx['skipGlobalErrors'])
        ? (ctx['skipGlobalErrors'] as string[])
        : [];

      const toastErrors = extractGraphqlErrorTranslations(
        error.errors,
        (key, params) => translocoService.translate(key, params),
        skipCodes,
      );

      if (toastErrors.length > 0) {
        errorHandler.handleGraphQLErrors(toastErrors);
      }
    } else {
      // network or other error
      const statusCode =
        'status' in error ? ((error as Record<string, unknown>)['status'] as number) : undefined;
      errorHandler.handleNetworkError(error, statusCode);
    }

    // No retry: every non-refresh path returns void (the callback may return an
    // Observable only to retry the operation).
    return;
  });

  // errorLink MUST come before headersLink so that forward(operation) on retry
  // re-runs headersLink and re-attaches the fresh Bearer token. See JSDoc above.
  return ApolloLink.from([errorLink, headersLink, httpLink]);
};

// ---------------------------------------------------------------------------
// apolloFactory — Angular DI entry point
// ---------------------------------------------------------------------------

export const apolloFactory = (): ApolloClient.Options => {
  const translocoService = inject(TranslocoService);
  const errorHandler = inject(GRAPHQL_ERROR_HANDLER);
  const authHandler = inject(GRAPHQL_AUTH_HANDLER);
  const tokenStore = inject(TokenStore);

  const http = new UploadHttpLink({ uri: environment.api.graphql }) as ApolloLink;

  const link = createAuroraGraphqlLinks({
    translocoService,
    errorHandler,
    authHandler,
    tokenStore,
    httpLink: http,
  });

  const cache = new InMemoryCache();

  const defaultOptions: ApolloClient.DefaultOptions.Input = {
    watchQuery: {
      fetchPolicy: 'no-cache',
      errorPolicy: 'ignore',
    },
    query: {
      fetchPolicy: 'no-cache',
      errorPolicy: 'all',
    },
  };

  return { link, cache, defaultOptions };
};
