import {
  AdminRestApiClient,
  createAdminRestApiClient,
} from '@shopify/admin-api-client';
import * as LosslessJSON from 'lossless-json';

import {
  clientLoggerFactory,
  getUserAgent,
  throwFailedRequest,
} from '../../common';
import {
  HashFormat,
  NormalizedRequest,
  abstractFetch,
  canonicalizeHeaders,
  createSHA256HMAC,
  getHeader,
} from '../../../../runtime';
import {ConfigInterface} from '../../../base-types';
import * as ShopifyErrors from '../../../error';
import {logger} from '../../../logger';
import {
  RestRequestReturn,
  PageInfo,
  RestClientParams,
  PageInfoParams,
} from '../types';
import type {
  RequestParams,
  GetRequestParams,
  PutRequestParams,
  PostRequestParams,
  DeleteRequestParams,
} from '../../types';
import {ApiVersion, Method} from '../../../types';
import {Session} from '../../../session/session';

export interface RestClientClassParams {
  config: ConfigInterface;
  formatPaths?: boolean;
}

interface DeprecationInterface {
  message: string | null;
  path: string;
  body?: string;
}

export class RestClient {
  public static config: ConfigInterface;
  public static formatPaths: boolean;

  static LINK_HEADER_REGEXP = /<([^<]+)>; rel="([^"]+)"/;
  static DEFAULT_LIMIT = '50';
  static RETRY_WAIT_TIME = 1000;

  static readonly DEPRECATION_ALERT_DELAY = 300000;
  loggedDeprecations: Record<string, number> = {};

  readonly client: AdminRestApiClient;
  readonly session: Session;
  readonly apiVersion: ApiVersion;

  public constructor({session, apiVersion}: RestClientParams) {
    const config = this.restClass().config;

    if (!config.isCustomStoreApp && !session.accessToken) {
      throw new ShopifyErrors.MissingRequiredArgument(
        'Missing access token when creating REST client',
      );
    }

    if (apiVersion) {
      const message =
        apiVersion === config.apiVersion
          ? `REST client has a redundant API version override to the default ${apiVersion}`
          : `REST client overriding default API version ${config.apiVersion} with ${apiVersion}`;

      logger(config).debug(message);
    }

    const customStoreAppAccessToken =
      config.adminApiAccessToken ?? config.apiSecretKey;

    this.session = session;
    this.apiVersion = apiVersion ?? config.apiVersion;
    this.client = createAdminRestApiClient({
      scheme: config.hostScheme,
      storeDomain: session.shop,
      apiVersion: apiVersion ?? config.apiVersion,
      accessToken: config.isCustomStoreApp
        ? customStoreAppAccessToken
        : session.accessToken!,
      customFetchApi: abstractFetch,
      logger: clientLoggerFactory(config),
      userAgentPrefix: getUserAgent(config),
      defaultRetryTime: this.restClass().RETRY_WAIT_TIME,
      formatPaths: this.restClass().formatPaths,
      isTesting: config.isTesting,
    });
  }

  /**
   * Performs a GET request on the given path.
   */
  public async get<T = any>(params: GetRequestParams) {
    return this.request<T>({method: Method.Get, ...params});
  }

  /**
   * Performs a POST request on the given path.
   */
  public async post<T = any>(params: PostRequestParams) {
    return this.request<T>({method: Method.Post, ...params});
  }

  /**
   * Performs a PUT request on the given path.
   */
  public async put<T = any>(params: PutRequestParams) {
    return this.request<T>({method: Method.Put, ...params});
  }

  /**
   * Performs a DELETE request on the given path.
   */
  public async delete<T = any>(params: DeleteRequestParams) {
    return this.request<T>({method: Method.Delete, ...params});
  }

  protected async request<T = any>(
    params: RequestParams,
  ): Promise<RestRequestReturn<T>> {
    const requestParams = {
      headers: {
        ...params.extraHeaders,
        ...(params.type ? {'Content-Type': params.type.toString()} : {}),
      },
      retries: params.tries ? params.tries - 1 : undefined,
      searchParams: params.query,
    };

    let response: Response;
    switch (params.method) {
      case Method.Get:
        response = await this.client.get(params.path, requestParams);
        break;
      case Method.Put:
        response = await this.client.put(params.path, {
          ...requestParams,
          data: params.data!,
        });
        break;
      case Method.Post:
        response = await this.client.post(params.path, {
          ...requestParams,
          data: params.data!,
        });
        break;
      case Method.Delete:
        response = await this.client.delete(params.path, requestParams);
        break;
      default:
        throw new ShopifyErrors.InvalidRequestError(
          `Unsupported request method '${params.method}'`,
        );
    }

    const bodyString: string = await response.text();

    // Some DELETE requests return an empty body but are still valid responses, we want those to go through
    const body: any =
      params.method === Method.Delete && bodyString === ''
        ? {}
        : this.parseJsonWithLosslessNumbers(bodyString);

    const responseHeaders = canonicalizeHeaders(
      Object.fromEntries(response.headers.entries()),
    );

    if (!response.ok) {
      throwFailedRequest(body, (params.tries ?? 1) > 1, response);
    }

    const requestReturn: RestRequestReturn<T> = {
      body,
      headers: responseHeaders,
    };

    await this.logDeprecations(
      {
        method: params.method,
        url: params.path,
        headers: requestParams.headers,
        body: params.data ? JSON.stringify(params.data) : undefined,
      },
      requestReturn,
    );

    const link = response.headers.get('Link');
    if (link !== undefined) {
      const pageInfo: PageInfo = {
        limit: params.query?.limit
          ? params.query?.limit.toString()
          : RestClient.DEFAULT_LIMIT,
      };

      if (link) {
        const links = link.split(', ');

        for (const link of links) {
          const parsedLink = link.match(RestClient.LINK_HEADER_REGEXP);
          if (!parsedLink) {
            continue;
          }

          const linkRel = parsedLink[2];
          const linkUrl = new URL(parsedLink[1]);
          const linkFields = linkUrl.searchParams.get('fields');
          const linkPageToken = linkUrl.searchParams.get('page_info');

          if (!pageInfo.fields && linkFields) {
            pageInfo.fields = linkFields.split(',');
          }

          if (linkPageToken) {
            switch (linkRel) {
              case 'previous':
                pageInfo.previousPageUrl = parsedLink[1];
                pageInfo.prevPage = this.buildRequestParams(parsedLink[1]);
                break;
              case 'next':
                pageInfo.nextPageUrl = parsedLink[1];
                pageInfo.nextPage = this.buildRequestParams(parsedLink[1]);
                break;
            }
          }
        }
      }

      requestReturn.pageInfo = pageInfo;
    }

    return requestReturn;
  }

  private restClass() {
    return this.constructor as typeof RestClient;
  }

  /**
   * Parse JSON with lossless-json to preserve numeric precision.
   * Converts all ID fields (ending with _id, _ids, or named 'id') to strings.
   */
  private parseJsonWithLosslessNumbers(jsonString: string): any {
    // Parse with lossless-json first to preserve precision
    const parsed = LosslessJSON.parse(jsonString);

    // Recursively process the parsed object to convert IDs to strings
    const processValue = (value: any, key?: string): any => {
      if (value === null || value === undefined) {
        return value;
      }

      // Handle LosslessNumber instances
      if (value && value.isLosslessNumber === true) {
        const keyLower = (key || '').toLowerCase();
        // Always convert ID fields to strings
        if (keyLower === 'id' || keyLower.endsWith('_id')) {
          return value.toString();
        }
        // For non-ID fields, always convert to regular JavaScript number
        // The IDs have already been handled, so we can use standard conversion
        return Number(value.value);
      }

      // Handle arrays - special case for _ids arrays
      if (Array.isArray(value)) {
        const isIdsArray = key && key.toLowerCase().endsWith('_ids');
        return value.map((item) => {
          // If this is an _ids array and item is a LosslessNumber, convert to string
          if (isIdsArray && item && item.isLosslessNumber === true) {
            return item.toString();
          }
          return processValue(item);
        });
      }

      // Handle objects
      if (typeof value === 'object') {
        const result: any = {};
        for (const objKey in value) {
          if (Object.prototype.hasOwnProperty.call(value, objKey)) {
            result[objKey] = processValue(value[objKey], objKey);
          }
        }
        return result;
      }

      return value;
    };

    return processValue(parsed);
  }

  private buildRequestParams(newPageUrl: string): PageInfoParams {
    const pattern = `^/admin/api/[^/]+/(.*).json$`;

    const url = new URL(newPageUrl);
    const path = url.pathname.replace(new RegExp(pattern), '$1');
    return {
      path,
      query: Object.fromEntries(url.searchParams.entries()),
    };
  }

  private async logDeprecations(
    request: NormalizedRequest,
    response: RestRequestReturn,
  ) {
    const config = this.restClass().config;

    const deprecationReason = getHeader(
      response.headers,
      'X-Shopify-API-Deprecated-Reason',
    );
    if (deprecationReason) {
      const deprecation: DeprecationInterface = {
        message: deprecationReason,
        path: request.url,
      };

      if (request.body) {
        // This can only be a string, since we're always converting the body before calling this method
        deprecation.body = `${(request.body as string).substring(0, 100)}...`;
      }

      const depHash = await createSHA256HMAC(
        config.apiSecretKey,
        JSON.stringify(deprecation),
        HashFormat.Hex,
      );

      if (
        !Object.keys(this.loggedDeprecations).includes(depHash) ||
        Date.now() - this.loggedDeprecations[depHash] >=
          RestClient.DEPRECATION_ALERT_DELAY
      ) {
        this.loggedDeprecations[depHash] = Date.now();

        const stack = new Error().stack;
        const message = `API Deprecation Notice ${new Date().toLocaleString()} : ${JSON.stringify(
          deprecation,
        )}  -  Stack Trace: ${stack}`;
        await logger(config).warning(message);
      }
    }
  }
}

export function restClientClass(
  params: RestClientClassParams,
): typeof RestClient {
  const {config, formatPaths} = params;

  class NewRestClient extends RestClient {
    public static config = config;
    public static formatPaths = formatPaths === undefined ? true : formatPaths;
  }

  Reflect.defineProperty(NewRestClient, 'name', {
    value: 'RestClient',
  });

  return NewRestClient as typeof RestClient;
}
