import {
  Configuration,
  ContextEnvEnum,
  ContextSourceEnum,
  CustomerServiceApi,
  CustomerServiceProvider,
  DefaultApi,
  FormType,
  InferenceApi,
  ProductsApi,
  ReportSessionRequest,
  ResponseCategory,
  ResponseError,
  UserEventCategory,
  V1GetSessionMessages200Response,
  V1OrgConfigGetRequest,
  V1OrgConfigGetSourceEnum,
} from '@spiffy-ai/commerce-api-client';
import type { Context } from '@spiffy-ai/commerce-api-client/dist/models/Context';
import type { ProductRetrievalResult } from 'src/atoms/search/types';
import Logger from 'src/application/logging/logger';
import {
  Message,
  NextMessageRequest,
  Response,
  Suggestion,
  SupportedEventRequest,
  UserEvent,
} from 'src/application/models';
import { OrgConfig } from 'src/application/models/api/orgConfigResults';
import { ProductExperiment } from 'src/application/models/productExperiment';
import { validateOrgConfigResults } from 'src/application/models/validators/validateOrgConfigResults';
import { validateResponse } from 'src/application/models/validators/validateResponse';
import { validateSuggestion } from 'src/application/models/validators/validateSuggestion';
import { validateUserEvent } from 'src/application/models/validators/validateUserEvent';
import {
  getQueryParam,
  messageFromQueryEvent,
  messageFromResponse,
  messageFromSuggestionEvent,
} from 'src/application/utils';
import { messageFromFormSubmittedEvent } from 'src/application/utils/messageFromFormSubmittedEvent';
import { messageRequestToCommerceMessageRequest } from 'src/application/utils/nextMessageRequestToApiRequest';
import { coreSupportedEventRequestToApiRequest } from 'src/application/utils/supportedEventRequestToApiRequest';
import { chatIdAtom, userIdAtom } from 'src/atoms/app'; // Import userIdAtom and chatIdAtom
import { SupportedEvent } from 'src/atoms/app/variant';
import { getAtomStore } from 'src/atoms/atomStore/atomStore';
import {
  baseUrlAtom,
  contextSourceAtom,
  envAtom,
  orgIdAtom,
  orgLevelApiKeyAtom,
  orgShortNameAtom,
  reactAppNameAtom, // Import envAtom
} from 'src/atoms/envive/enviveConfig';
import { featureFlagServiceAtom } from 'src/atoms/org/graphqlConfig'; // Import new orgIdAtom and featureFlagServiceAtom
import { SessionRestartRequired } from 'src/types/exceptions/sessionExceptions';
import { UnsupportedProductException } from 'src/types/exceptions/unsupportedProductExceptions';
import { WidgetText } from './models/api/widgetText';
import { WidgetTextRequest } from './models/api/widgetTextRequest';
import { productRetrievalResponseToProductRetrievalResult } from './utils/productRetrievalResponseToProductRetrievalResult';
import { apiWidgetTextResponseToCoreWidgetText } from './utils/widgetTextFromApiWidgetTextResponse';
import { coreWidgetTextRequestToApiRequest } from './utils/widgetTextRequestToApiRequest';

const logger = new Logger('commerce-api');

async function errorResponseBody(error: ResponseError) {
  try {
    return await error.response.json();
  } catch {
    return {};
  }
}

async function throwSessionRestartRequiredIf(errorMsg: string, error: unknown) {
  if (!(error instanceof ResponseError)) {
    logger.logInfo(errorMsg, error);
    throw error;
  }

  const errorResponse = await errorResponseBody(error);
  if (
    errorResponse?.message?.toLowerCase() === 'unsupported product' || // for backward compatibility. newer versions of the API return sub_code instead of message
    errorResponse?.app_code?.toUpperCase() === 'PRODUCT_NOT_FOUND'
  ) {
    throw new UnsupportedProductException();
  } else if (
    errorResponse?.app_code?.toUpperCase() === 'RESTART_SESSION' ||
    errorResponse?.sub_code?.toUpperCase() === 'NOT_FOUND' // for backward compatibility. new API responses will contain "app_code"
  ) {
    logger.logInfo(
      'Session does not exist. Re-start session',
      error,
      error.response,
      errorResponse,
    );
    throw new SessionRestartRequired();
  }

  logger.logInfo(errorMsg, error);
  throw error;
}

class CommerceApiClient {
  private readonly defaultApi: DefaultApi;

  private readonly customerServiceApi: CustomerServiceApi;

  private readonly inferenceApi: InferenceApi;

  private readonly productsApi: ProductsApi;

  private static instance: CommerceApiClient | undefined;

  private suggestionsAbortController = new AbortController();

  private responsesAbortController = new AbortController();

  private static getInstance = (): CommerceApiClient => {
    if (!CommerceApiClient.instance) {
      CommerceApiClient.instance = new CommerceApiClient();
    }

    return CommerceApiClient.instance;
  };

  private constructor(basePath?: string) {
    const atomStore = getAtomStore();
    const baseUrl = atomStore.get(baseUrlAtom);
    const apiKey = atomStore.get(orgLevelApiKeyAtom);
    const path = basePath || baseUrl;
    // API Key is now handled at the EnviveConfigProvider level
    const config: Configuration = new Configuration({
      accessToken: apiKey,
      basePath: path,
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
    });
    this.defaultApi = new DefaultApi(config);
    this.inferenceApi = new InferenceApi(config);
    this.customerServiceApi = new CustomerServiceApi(config);
    this.productsApi = new ProductsApi(config);
  }

  static resolveUrl = async (url: string) => {
    const atomStore = getAtomStore();
    const orgShortName = atomStore.get(orgShortNameAtom);
    const orgId = atomStore.get(orgIdAtom);
    const userId = atomStore.get(userIdAtom);
    const chatId = atomStore.get(chatIdAtom);
    const source = atomStore.get(contextSourceAtom);
    const env = atomStore.get(envAtom);

    const featureFlagService = atomStore.get(featureFlagServiceAtom);

    const context: Context = {
      user_id: userId ?? '',
      org_id: orgId ?? '',
      org_short_name: orgShortName ?? '',
      chat_id: chatId ?? '',
      source: source ?? ContextSourceEnum.App,
      env: (env as ContextEnvEnum) ?? ContextEnvEnum.Dev, // Cast env to ContextEnvEnum
    };

    const overrideConfigVersion =
      getQueryParam('spiffy_config_version') || getQueryParam('envive_config_version') || undefined;

    const featureGates = featureFlagService?.featureFlagService?.getFeatureFlags() || {};
    const urlResolvingRequest = {
      url,
      context,
      feature_gates: featureGates,
      override_config_version: overrideConfigVersion,
    };

    const rawResponse = await CommerceApiClient.getInstance().inferenceApi.v1UrlResolvingPostRaw({
      UrlResolvingRequest: urlResolvingRequest,
    });

    const responseBody = await rawResponse.raw.json();

    return responseBody;
  };

  static reportSession = async (reportRequest: ReportSessionRequest): Promise<void> => {
    await CommerceApiClient.getInstance().defaultApi.v1ChatsReportSessionIdPost({
      ReportSessionRequest: reportRequest,
    });
  };

  static getNextResponses = async (payload: NextMessageRequest): Promise<Message[]> => {
    try {
      const response = await CommerceApiClient.getInstance().inferenceApi.v1NextResponsesPost({
        NextMessageRequest: messageRequestToCommerceMessageRequest(payload),
      });
      const messages = response
        .map(resp => validateResponse(resp))
        .map(resp => messageFromResponse(resp));

      return messages.filter((m): m is Message => m != null);
    } catch (err: unknown) {
      logger.logInfo('Failed to get next responses', err, {
        payloadContext: payload?.context,
        userEvents: payload?.userEvents,
      });
      await throwSessionRestartRequiredIf('Failed to get next responses', err);
      return [];
    }
  };

  static getNextResponseStreaming = (
    payload: NextMessageRequest,
  ): AsyncGenerator<Response, void, unknown> => {
    async function* generate(inferenceApi: InferenceApi, abortController: AbortController) {
      // make sure streaming is enabled
      try {
        const response = await inferenceApi.v1NextResponsesPostRaw(
          {
            NextMessageRequest: messageRequestToCommerceMessageRequest(payload),
          },
          { signal: abortController.signal },
        );

        // Read the response as a stream of data
        if (!response.raw.body) {
          logger.logError('No body in the streamed response', undefined, {
            response: response.raw,
          });
          return;
        }

        const reader = response.raw.body.getReader();
        const decoder = new TextDecoder('utf-8');

        let partial = '';
        // TODO this function is recreated every time new data comes from the stream - define it outside of the generator
        const safeParse = (line: string): unknown => {
          try {
            return JSON.parse(line); // Parse the JSON string
          } catch (err) {
            logger.logError('Error parsing streamed line', err, {
              line,
              partial,
            });
            // swallow the error and set the partial to our current chunk
            partial = line;
            return partial;
          }
        };

        // TODO this function is recreated every time new data comes from the stream - define it outside of the generator
        const processChunk = (chunk: string): unknown[] => {
          // merge the partial with the incoming chunk
          const lines = `${partial}${chunk}`.split('\n');

          const parsedLines = lines
            .map(line => line.replace(/^data: /, '').trim()) // Remove the "data: " prefix
            .filter(line => line !== '' && line !== '[DONE]') // Remove empty lines and "[DONE]"
            .map(safeParse)
            .filter(v => v); // and filter out the undefined values
          return parsedLines;
        };

        while (true) {
          // eslint-disable-next-line no-await-in-loop
          const { done, value } = await reader.read();

          if (done) {
            break;
          }

          // Massage and parse the chunk of data
          const chunk = decoder.decode(value);
          const parsedLines = processChunk(chunk);

          for (const parsedLine of parsedLines) {
            const validatedResponse = validateResponse(parsedLine);

            if (validatedResponse) {
              yield validatedResponse;
            }
          }
        }
      } catch (error: unknown) {
        logger.logError('Failed to get next streaming responses', error, {
          payloadContext: payload?.context,
          userEvents: payload?.userEvents,
        });
        await throwSessionRestartRequiredIf('Failed to get next streaming responses', error);
      }
    }

    CommerceApiClient.getInstance().responsesAbortController.abort();
    CommerceApiClient.getInstance().responsesAbortController = new AbortController();

    return generate(
      CommerceApiClient.getInstance().inferenceApi,
      CommerceApiClient.getInstance().responsesAbortController,
    );
  };

  static getNextSuggestions = async (payload: NextMessageRequest): Promise<Suggestion[]> => {
    try {
      CommerceApiClient.getInstance().suggestionsAbortController.abort();
      CommerceApiClient.getInstance().suggestionsAbortController = new AbortController();

      const response = await CommerceApiClient.getInstance().inferenceApi.v1NextSuggestionsPost(
        {
          NextMessageRequest: messageRequestToCommerceMessageRequest(payload),
        },
        {
          signal: CommerceApiClient.getInstance().suggestionsAbortController.signal,
        },
      );

      const suggestions = response
        .map(resp => validateSuggestion(resp))
        .filter((suggestion): suggestion is Suggestion => suggestion != null);

      return suggestions;
    } catch (error: unknown) {
      logger.logInfo('Failed to get suggestions', error, {
        payloadContext: payload?.context,
        userEvents: payload?.userEvents,
      });

      await throwSessionRestartRequiredIf('Failed to get suggestions', error);
      return [];
    }
  };

  /**
   * Fetches the existing chat data for a given session and transforms them to reconstruct the chat history.
   *
   * @param orgId The organization Id
   * @param chatId The existing chat Id
   * @param userId The user Id
   *
   * @returns A list of messages that were exchanged in the chat, throws an error if the chat session has expired.
   */
  static getResponses = async (
    orgId: string,
    chatId: string,
    userId: string,
  ): Promise<{
    responses: Response[][];
    userEvents: UserEvent[];
    suggestions: Suggestion[];
    messages: Message[][];
  }> => {
    let data: V1GetSessionMessages200Response = {
      responses: [],
      suggestions: [],
      user_events: [],
    };
    const request = {
      org_id: orgId,
      chat_id: chatId,
      user_id: userId,
    };
    try {
      data = await CommerceApiClient.getInstance().defaultApi.v1GetSessionMessages(request);
    } catch (error: unknown) {
      await throwSessionRestartRequiredIf('Failed to get chat responses', error);
    }

    const responses: Response[][] = data?.responses?.map(turn =>
      turn
        .map(response => validateResponse(response))
        .filter((response): response is Response => response != null),
    );

    const suggestions: Suggestion[] = data?.suggestions
      .map(suggestion => validateSuggestion(suggestion))
      .filter((suggestion): suggestion is Suggestion => suggestion != null);

    const userEvents: UserEvent[] = data?.user_events
      .map(event => validateUserEvent(event))
      .filter((event): event is UserEvent => event != null);

    // if a form has already been submitted, don't show it in the chat history
    const formSubmittedUserEventsFormIds = userEvents
      .filter(event => event.category === UserEventCategory.FormSubmitted)
      .map(event => event.attributes.formResponseId);

    const assistantMessages: Message[][] = responses
      .map(turn =>
        turn
          .filter(
            response =>
              !(
                response.category === ResponseCategory.Form &&
                formSubmittedUserEventsFormIds.includes(response.id)
              ),
          )
          .map(response => messageFromResponse(response))
          .filter((message): message is Message => message != null),
      )
      .filter(turn => turn.length > 0);

    const userMessages: Message[][] = userEvents
      .map(event => {
        if ([UserEventCategory.QueryTyped, UserEventCategory.Search].includes(event.category)) {
          return [messageFromQueryEvent(event)];
        }

        if (event.category === UserEventCategory.SuggestionClicked) {
          return [messageFromSuggestionEvent(event, suggestions)];
        }

        if (event.category === UserEventCategory.FormSubmitted) {
          const formResponse = responses
            .flat()
            .find(
              response =>
                response.id === event.attributes.formResponseId &&
                event.attributes.formType !== FormType.Escalation,
            );

          if (formResponse && formResponse.category === ResponseCategory.Form) {
            return [messageFromFormSubmittedEvent(event, formResponse.attributes)];
          }
        }

        return [];
      })
      .filter((message): message is Message[] => message.length > 0);

    // Sort the messages chronologically to reconstruct the chat history
    const sortedMessages = [...assistantMessages, ...userMessages].sort(
      (a, b) => new Date(a[0].createdAt).getTime() - new Date(b[0].createdAt).getTime(),
    );

    return { responses, userEvents, suggestions, messages: sortedMessages };
  };

  static getHardcopy = async (request: WidgetTextRequest): Promise<WidgetText> => {
    const apiRequest = coreWidgetTextRequestToApiRequest(request);
    const response = await CommerceApiClient.getInstance().inferenceApi.v1WidgetTextPost({
      WidgetTextRequest: apiRequest,
    });
    return apiWidgetTextResponseToCoreWidgetText(apiRequest, response);
  };

  /**
   * Retrieves product details for the given product IDs via POST /v1/products/retrieve.
   * Builds context from the current atom store (org, user, chat, etc.) and returns
   * products in the shape expected by ProductRetrievalResult (camelCase attributes).
   *
   * @param productIds - List of product IDs to retrieve
   * @returns ProductRetrievalResult with products and totalProductCount
   */
  static retrieveProducts = async (productIds: string[]): Promise<ProductRetrievalResult> => {
    if (productIds.length === 0) {
      return { products: [], totalProductCount: 0 };
    }

    try {
      const atomStore = getAtomStore();
      const orgShortName = atomStore.get(orgShortNameAtom);
      const orgId = atomStore.get(orgIdAtom);
      const userId = atomStore.get(userIdAtom);
      const chatId = atomStore.get(chatIdAtom);
      const source = atomStore.get(contextSourceAtom);
      const env = atomStore.get(envAtom);

      const context: Context = {
        user_id: userId ?? '',
        org_id: orgId ?? '',
        org_short_name: orgShortName ?? '',
        chat_id: chatId ?? '',
        source: source ?? ContextSourceEnum.App,
        env: (env as ContextEnvEnum) ?? ContextEnvEnum.Dev,
      };

      const response = await CommerceApiClient.getInstance().productsApi.v1ProductsRetrievePost({
        ProductRetrievalRequest: {
          context,
          product_ids: productIds,
        },
      });

      return productRetrievalResponseToProductRetrievalResult(response);
    } catch (err) {
      await throwSessionRestartRequiredIf('Failed to retrieve products', err);
      return { products: [], totalProductCount: 0 };
    }
  };

  /**
   *
   * @param payload
   * @returns
   */
  static isSupportedEvent = async (payload: SupportedEventRequest): Promise<SupportedEvent> => {
    try {
      const rawResponse =
        await CommerceApiClient.getInstance().inferenceApi.v1SupportedEventPostRaw({
          SupportedEventRequest: coreSupportedEventRequestToApiRequest(payload),
        });

      // Get the actual HTTP response JSON
      const httpResponseText = await rawResponse.raw.text();
      const httpResponseJson = JSON.parse(httpResponseText);

      return {
        ...httpResponseJson,
        numberOfReviews: httpResponseJson.num_of_reviews,
        merchant_tags: httpResponseJson.merchant_tags || [],
      };
    } catch (err) {
      logger.logError('Failed to get response for v1SupportedEventPost', {
        err,
      });
      return {
        supported: false,
        ready: false,
        category: undefined,
        collections: [],
        numberOfReviews: undefined,
        top_category: undefined,
        merchant_tags: [],
      };
    }
  };

  private static mapContextSourceToV1OrgConfigGetSource = (
    source: ContextSourceEnum | undefined,
  ): V1OrgConfigGetSourceEnum | undefined => {
    if (source === undefined) return undefined;
    switch (source) {
      case ContextSourceEnum.Fork:
        return V1OrgConfigGetSourceEnum.Fork;
      case ContextSourceEnum.Playground:
        return V1OrgConfigGetSourceEnum.Playground;
      case ContextSourceEnum.App:
        return V1OrgConfigGetSourceEnum.App;
      case ContextSourceEnum.Test:
        return V1OrgConfigGetSourceEnum.Test;
      case ContextSourceEnum.Eval:
        return V1OrgConfigGetSourceEnum.Test;
      default: {
        // This ensures that if new values are added to ContextSourceEnum, we catch it
        const exhaustiveCheck: never = source;
        return exhaustiveCheck;
      }
    }
  };

  static getOrgConfig = async (user_id: string): Promise<OrgConfig | undefined> => {
    try {
      const atomStore = getAtomStore();
      const reactAppName = atomStore.get(reactAppNameAtom);
      const contextSource = atomStore.get(contextSourceAtom);
      const featureFlagService = atomStore.get(featureFlagServiceAtom); // Get featureFlagService
      const request: V1OrgConfigGetRequest = {
        namespace: reactAppName,
        user_id,
        source: this.mapContextSourceToV1OrgConfigGetSource(contextSource),
        include_experiments: Object.values(ProductExperiment),
        include_feature_gates: Object.entries(
          featureFlagService?.featureFlagService?.getFeatureFlags() || {},
        )
          .filter(([, isEnabled]) => isEnabled)
          .map(([featureGateName]) => featureGateName), // Use featureFlagService
      };
      const response = await CommerceApiClient.getInstance().defaultApi.v1OrgConfigGet(request);

      return validateOrgConfigResults(response);
    } catch (err) {
      logger.logError(`Failed to get org config`, err, { err });
      return undefined;
    }
  };

  static addNoteToLatestConversation = async (
    spiffyUserId: string,
    email: string,
    customerServiceProvider: CustomerServiceProvider,
  ) => {
    logger.logInfo(
      `addNoteToLatestConversation - user_id=${spiffyUserId} email=${email} customer_service_provider=${customerServiceProvider}`,
    );
    try {
      await CommerceApiClient.getInstance().customerServiceApi.v1CustserviceAddNoteToLatestConversationPost(
        {
          AddNoteToLatestConversationRequest: {
            spiffy_user_id: spiffyUserId,
            email,
            customer_service_provider: customerServiceProvider,
          },
        },
      );
    } catch (err) {
      logger.logError('Failed to add note to latest conversation', { err });
    }
  };

  static getCustomerServiceApi = () => CommerceApiClient.getInstance().customerServiceApi;
}

export default CommerceApiClient;
