import { Storefront } from 'storefrontSdkV1';
import { StorefrontClient } from '@nacelle/storefront-sdk';
import { CommerceQueries } from '@nacelle/commerce-queries-plugin';
import { NacelleGraphQLConnector } from '@nacelle/client-js-sdk';
import {
  checkVariantAvailability,
  sanitizeIntegerValue as integerValue,
  toIetfLocale,
  transformCollections,
  transformContent,
  transformProducts,
  transformSpace,
  graphqlQuery,
  checkStorefrontSdkMethod
} from '../utils';
import {
  allProductCollectionsProductHandlesQuery,
  allProductCollectionsProductsQuery,
  allProductCollectionsQuery
} from '../queries';
import type {
  FetchArticleParams,
  FetchArticlesParams,
  FetchBlogPageParams,
  FetchBlogParams,
  FetchCollectionPageParams,
  FetchCollectionParams,
  FetchContentParams,
  FetchPageParams,
  FetchPagesParams,
  FetchProductParams,
  FetchProductsParams
} from '@nacelle/client-js-sdk';
import type {
  NacelleCollection,
  NacelleContent,
  NacelleProduct,
  NacelleShopSpace
} from '@nacelle/types';
import type {
  Content,
  FetchContentMethodParams,
  FetchMethodParams,
  Product,
  ProductEdge,
  StorefrontInstance,
  StorefrontConfig as StorefrontConfigv1
} from 'storefrontSdkV1';
import type {
  ProductCollectionGraphQLResponse,
  ProductCollectionNode,
  ProductCollectionWithProductConnection
} from '../utils';

export const notFoundMessages = {
  product: 'Product was not found.',
  collection: 'Collection was not found.',
  content: 'Content was not found.'
};

const ClientWithCommerceQueries = CommerceQueries(StorefrontClient);

export type StorefrontInstanceV2 = InstanceType<
  typeof ClientWithCommerceQueries
>;

export interface CompatibilityConnectorParams {
  /**
   * Your Nacelle v2 Storefront Endpoint. The `token` parameter is also required if the `endpoint` parameter is provided.
   */
  endpoint?: string;

  /**
   * Your Nacelle v2 Public Storefront Token. The `endpoint` parameter is also required if the `token` parameter is provided.
   */
  token?: string;

  /**
   * A Nacelle v2 Storefront client. A `client` parameter or `endpoint` and `token` parameters must be provided.
   */
  client?: StorefrontInstance | StorefrontInstanceV2;

  /**
   * The default locale used by the various `client.data` methods.
   *
   * @defaultValue `'en-US'`
   *
   * @example
   * locale: 'en-US'
   *
   * @example
   * locale: 'es-MX'
   */
  locale?: string;
}

type WithEntryDepth<T> = T & {
  entryDepth?: number;
};

export interface FetchAllContentParams {
  limit?: number;
  locale?: string;
  type?: string;

  /**
   * @deprecated For best results, leave `queryLimit` unset.
   */
  queryLimit?: number;
}

export interface FetchAllProductsParams {
  limit?: number;
  locale?: string;

  /**
   * @deprecated For best results, leave `queryLimit` unset.
   */
  queryLimit?: number;
}

export interface FetchAllCollectionsParams {
  limit?: number;
  locale?: string;

  /**
   * @deprecated For best results, leave `queryLimit` unset.
   */
  queryLimit?: number;
}

export default class NacelleCompatibilityConnector extends NacelleGraphQLConnector {
  client: StorefrontInstance | StorefrontInstanceV2;
  locale: string;
  spaceId: string;

  constructor(params: CompatibilityConnectorParams) {
    let token: string;
    let endpoint: string;

    if (params.client) {
      token =
        (params.client.getConfig() as StorefrontConfigv1).token ?? '<no-token>';
      endpoint = params.client.getConfig().storefrontEndpoint;
    } else {
      if (!params.token) {
        throw new Error(
          '@nacelle/compatibility-connector requires a valid Nacelle Public Storefront token.'
        );
      }
      if (!params.endpoint) {
        throw new Error(
          '@nacelle/compatibility-connector requires a valid Nacelle Storefront Endpoint.'
        );
      }
      token = params.token;
      endpoint = params.endpoint;
    }

    const endpointRegex = /\/spaces\/([a-z0-9-]+)\/?/i;
    const endpointMatch = endpointRegex.exec(endpoint);

    if (!endpointMatch) {
      throw new Error(
        '@nacelle/compatibility-connector requires a valid Nacelle Storefront Endpoint.'
      );
    }

    super({
      endpoint,
      token,
      spaceId: endpointMatch[1]
    });

    this.locale = params.client?.getConfig().locale || params.locale || 'en-us';
    this.spaceId = endpointMatch[1];
    this.client =
      params.client ||
      Storefront({
        token,
        storefrontEndpoint: endpoint,
        locale: toIetfLocale(this.locale)
      });

    (['content', 'navigation', 'products', 'spaceProperties'] as const).forEach(
      (methodName) => checkStorefrontSdkMethod(this.client, methodName)
    );
  }

  // Gets article data
  async article(
    params: WithEntryDepth<FetchArticleParams>
  ): Promise<NacelleContent> {
    const contentParams: FetchContentMethodParams = {
      handles: [params.handle],
      type: 'article',
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: 1
    };
    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const articles = (await this.client.content(contentParams)) as Content[];
    if (!articles.length) {
      throw new Error(notFoundMessages.content);
    }

    return transformContent(articles, params.locale || this.locale)[0];
  }

  // Gets articles data
  async articles(
    params: WithEntryDepth<FetchArticlesParams>
  ): Promise<NacelleContent[]> {
    const contentParams: FetchContentMethodParams = {
      handles: params.handles,
      type: 'article',
      locale: toIetfLocale(params.locale || this.locale)
    };
    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    let articles = (await this.client.content(contentParams)) as Content[];
    if (params.blogHandle) {
      articles = articles.filter(
        (article) => article.fields?.blogHandle === params.blogHandle
      );
    }

    return transformContent(articles, params.locale || this.locale);
  }

  // Gets content data
  async content(
    params: WithEntryDepth<FetchContentParams>
  ): Promise<NacelleContent> {
    const contentParams: FetchContentMethodParams = {
      handles: [params.handle],
      type: params.type,
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: 1
    };
    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const content = (await this.client.content(contentParams)) as Content[];

    if (!content.length) {
      throw new Error(notFoundMessages.content);
    }

    return transformContent(content, params.locale || this.locale)[0];
  }

  // Gets all content data
  async allContent(
    params?: WithEntryDepth<FetchAllContentParams>
  ): Promise<NacelleContent[]> {
    const entryDepth = integerValue(params?.entryDepth, 0);
    const contentParams: FetchContentMethodParams = {
      maxReturnedEntries: -1, // Fetch all content entries
      type: params?.type
    };

    if (params?.queryLimit) {
      const entriesPerPage = integerValue(params.queryLimit, 50, 1, 250);
      contentParams.advancedOptions = { entriesPerPage };
    }

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const content = (await this.client.content(contentParams)) as Content[];
    const results = transformContent(content, params?.locale || this.locale);

    // Limit the length of the content array that's returned from `transformContent`
    if (params?.limit && params.limit > 0) {
      return results.slice(0, params.limit);
    }

    return results;
  }

  // Gets content page data
  async page(params: WithEntryDepth<FetchPageParams>): Promise<NacelleContent> {
    const contentParams: FetchContentMethodParams = {
      handles: [params.handle],
      type: 'page',
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: 1
    };

    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const content = (await this.client.content(contentParams)) as Content[];

    if (!content.length) {
      throw new Error(notFoundMessages.content);
    }

    return transformContent(content, params.locale || this.locale)[0];
  }

  // Gets content pages data
  async pages(
    params: WithEntryDepth<FetchPagesParams>
  ): Promise<NacelleContent[]> {
    const contentParams: FetchContentMethodParams = {
      handles: params.handles,
      type: 'page',
      locale: toIetfLocale(params.locale || this.locale)
    };

    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const content = (await this.client.content(contentParams)) as Content[];

    return transformContent(content, params.locale || this.locale);
  }

  // Gets blog data
  async blog(params: WithEntryDepth<FetchBlogParams>): Promise<NacelleContent> {
    const contentParams: FetchContentMethodParams = {
      handles: [params.handle],
      type: 'blog',
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: 1
    };

    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    const blogs = (await this.client.content(contentParams)) as Content[];

    if (!blogs.length) {
      throw new Error(notFoundMessages.content);
    }

    return transformContent(blogs, params.locale || this.locale)[0];
  }

  // Gets some or all articles belonging to a blog
  async blogPage(
    params: WithEntryDepth<FetchBlogPageParams>
  ): Promise<NacelleContent[]> {
    const {
      handle,
      locale,
      index = 0,
      itemsPerPage = 30,
      list = 'default',
      paginate
    } = params;
    let { blog } = params;
    let articles: Content[] = [];
    let handles: string[] = [];

    if (!blog) {
      if (!handle) {
        throw new Error('A blog or handle is required');
      }

      blog = await this.blog({
        handle,
        locale,
        entryDepth: params.entryDepth
      });
    }

    if (Array.isArray(blog.articleLists)) {
      const blogArticleHandles = blog.articleLists.find(
        (articleList) => articleList.slug === list
      )?.handles;

      if (Array.isArray(blogArticleHandles)) {
        handles = blogArticleHandles;
      }
    }

    if (paginate && handles.length) {
      handles = handles.slice(index, index + itemsPerPage);
    }

    const contentParams: FetchContentMethodParams = {
      handles,
      type: 'article',
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: handles.length
    };
    const entryDepth = integerValue(params?.entryDepth, 0);

    if (entryDepth) {
      contentParams.entryDepth = entryDepth;
    }

    articles = handles.length
      ? ((await this.client.content(contentParams)) as Content[])
      : [];

    return transformContent(articles, params.locale || this.locale);
  }

  // Gets product data
  async product(params: FetchProductParams): Promise<NacelleProduct> {
    const products = (await this.client.products({
      handles: [params.handle],
      locale: toIetfLocale(params.locale || this.locale),
      maxReturnedEntries: 1
    })) as Product[];

    if (!products.length) {
      throw new Error(notFoundMessages.product);
    }

    return transformProducts(products, params.locale || this.locale)[0];
  }

  // Gets products data
  async products(params: FetchProductsParams): Promise<NacelleProduct[]> {
    const products = (await this.client.products({
      handles: params.handles,
      locale: toIetfLocale(params.locale || this.locale)
    })) as Product[];

    return transformProducts(products, params.locale || this.locale);
  }

  // Gets all products data
  async allProducts(
    params?: FetchAllProductsParams
  ): Promise<NacelleProduct[]> {
    const maxReturnedEntries = integerValue(params?.limit, -1, -1);
    const productsParams: FetchMethodParams = { maxReturnedEntries };

    if (params?.queryLimit) {
      const entriesPerPage = integerValue(params.queryLimit, 50, 1, 250);
      productsParams.advancedOptions = { entriesPerPage };
    }

    const products = (await this.client.products(productsParams)) as Product[];

    return transformProducts(products, params?.locale || this.locale);
  }

  async isVariantAvailable(params: {
    productId: string;
    variantId: string;
  }): Promise<boolean> {
    const [product] = (await this.client.products({
      nacelleEntryIds: [params.productId],
      locale: toIetfLocale(this.locale),
      maxReturnedEntries: 1
    })) as Product[];

    return checkVariantAvailability({
      product,
      variantId: params.variantId
    });
  }

  // Gets space data
  async space(): Promise<NacelleShopSpace> {
    const spaceProperties = await this.client.spaceProperties();
    const navigation = await this.client.navigation();
    return transformSpace({
      spaceProperties,
      navigation,
      spaceId: this.spaceId
    });
  }

  // Get collection data with product handles, not full products.
  async collection(params: FetchCollectionParams): Promise<NacelleCollection> {
    const response = await graphqlQuery<ProductCollectionGraphQLResponse>(
      allProductCollectionsQuery,
      {
        filter: {
          handles: [params.handle],
          locale: toIetfLocale(params.locale || this.locale)
        }
      },
      this.client
    );

    const collections = await this.paginateProducts(
      response.allProductCollections.edges,
      {
        locale: toIetfLocale(params.locale || this.locale)
      }
    );

    if (!collections.length) {
      throw new Error(notFoundMessages.collection);
    }

    return transformCollections(collections, params.locale || this.locale)[0];
  }

  // Get only products within a collection
  // `productLists` don't exist in W2, so `list` parameter is ignored.
  async collectionPage({
    handle,
    locale,
    collection,
    paginate,
    index = 0,
    itemsPerPage = 30
  }: FetchCollectionPageParams): Promise<NacelleProduct[]> {
    if (typeof collection === 'undefined' && !handle) {
      throw new Error('A collection or handle is required');
    }

    if (collection) {
      let handles: string[] = [];

      if (
        collection.productLists?.length &&
        collection.productLists[0].handles
      ) {
        handles.push(...collection.productLists[0].handles);
      }

      if (!handles.length) {
        throw new Error('Selected list does not have array of handles');
      }

      if (paginate) {
        handles = handles.slice(index, index + itemsPerPage);
      }

      const collectionProducts = (await this.client.products({
        handles,
        locale: toIetfLocale(locale || this.locale),
        maxReturnedEntries: -1
      })) as Product[];

      return transformProducts(collectionProducts, locale || this.locale);
    }

    const response = await graphqlQuery<ProductCollectionGraphQLResponse>(
      allProductCollectionsProductsQuery,
      {
        filter: {
          handles: [handle],
          locale: toIetfLocale(locale || this.locale)
        }
      },
      this.client
    );

    const collections = await this.paginateProducts(
      response.allProductCollections.edges,
      {
        locale: toIetfLocale(locale || this.locale)
      },
      true
    );

    let products: Product[] = [];

    if (collections.length && collections[0].productConnection.edges) {
      const edges: ProductEdge[] = collections[0].productConnection.edges;
      products = edges.map((product) => product.node);
    }

    if (paginate && products) {
      products = products.slice(index, index + itemsPerPage);
    }

    return transformProducts(products, locale || this.locale);
  }

  async allCollections(
    params?: FetchAllCollectionsParams
  ): Promise<NacelleCollection[]> {
    let allCollections: ProductCollectionWithProductConnection[] = [];
    let keepFetching = true;
    let nextAfter;

    do {
      const first = integerValue(params?.queryLimit, 50, 1, 250);

      const response: ProductCollectionGraphQLResponse =
        await graphqlQuery<ProductCollectionGraphQLResponse>(
          allProductCollectionsQuery,
          {
            filter: {
              first,
              after: nextAfter
            }
          },
          this.client
        );

      const collections = await this.paginateProducts(
        response.allProductCollections.edges
      );
      allCollections = allCollections.concat(collections);
      nextAfter = response.allProductCollections.pageInfo.endCursor;
      keepFetching = response.allProductCollections.pageInfo.hasNextPage;
    } while (keepFetching);

    if (params?.limit) {
      allCollections = allCollections.slice(0, params.limit);
    }

    return transformCollections(allCollections, params?.locale || this.locale);
  }

  paginateProducts(
    collections: ProductCollectionNode[],
    filter?: { locale: string },
    fullProducts?: boolean
  ): Promise<ProductCollectionWithProductConnection[]> {
    return Promise.all(
      collections.map(async (collection) => {
        let productConnection = collection.node.productConnection.edges;
        let hasNextPage =
          collection.node.productConnection.pageInfo?.hasNextPage;
        let endCursor = collection.node.productConnection.pageInfo?.endCursor;
        while (hasNextPage) {
          const paginatedResponse =
            await graphqlQuery<ProductCollectionGraphQLResponse>(
              fullProducts
                ? allProductCollectionsProductsQuery
                : allProductCollectionsProductHandlesQuery,
              {
                filter: {
                  nacelleEntryIds: [collection.node.nacelleEntryId],
                  ...filter
                },
                after: endCursor
              },
              this.client
            );
          const paginatedCollection =
            paginatedResponse.allProductCollections.edges[0];

          const edges = paginatedCollection
            ? paginatedCollection.node.productConnection.edges
            : [];

          productConnection = [...productConnection, ...edges];
          hasNextPage =
            paginatedCollection &&
            paginatedCollection.node.productConnection.pageInfo?.hasNextPage;
          endCursor =
            paginatedCollection &&
            paginatedCollection.node.productConnection.pageInfo?.endCursor;
        }

        collection.node.productConnection.edges = productConnection;

        return collection.node;
      })
    );
  }
}
