import request, { Header } from '../utils/request'
import SdkSettings from '../settings'
import { Logger } from '../utils/logger'
import { Context } from '../api'
import { inBrowser } from '../utils/runtime'
import { PerProductConfigCache, selectSource, PerProductConfig } from './perProductConfig'

const iframeId = 'salsify-ec-iframe'
const prodCdnOrigin = 'https://salsify-ecdn.com'
const stagingCdnOrigin = 'https://staging.salsify-ecdn.com'
const testEnvCdnPattern = /^https:\/\/.+.test\.salsify\.com/
const cdnPrefix = 'sdk'
// The message types also used in enhanced-content-renderer for posting messages
const messageTypes = {
  heightUpdateRequest: 'heightUpdateRequest',
  contextRequest: 'contextRequest',
}
const defaultSource = 'index.html'

let attachedIframeEventListener = false

function messageFromSalsifyCDN(event: MessageEvent): boolean {
  return event.origin === prodCdnOrigin || event.origin === stagingCdnOrigin || testEnvCdnPattern.test(event.origin)
}

const attachIframeResizeListener = (logger: Logger): void => {
  if (attachedIframeEventListener) {
    return
  }

  window.addEventListener('message', event => {
    if (!messageFromSalsifyCDN(event)) {
      return
    }

    // if `messageType` isn't defined, it means the EC is an older version and sent
    // a height update request without the type included, so we need to handle it
    if (event.data.messageType && event.data.messageType !== messageTypes.heightUpdateRequest) {
      return
    }

    const selector = `#${iframeId}`
    const iframe = document.querySelector(selector) as HTMLIFrameElement
    if (iframe) {
      iframe.height = event.data.height
    } else {
      logger.log('error', {
        errorContext: 'iframeResizeListener',
        errorType: 'dom',
        errorMessage: `Could not find iframe with selector ${selector}`,
      })
    }
  })

  attachedIframeEventListener = true
}

/**
 * MessageEventSource can be one of the following: WindowProxy, MessagePort, or ServiceWorker.
 * For some browsers, WindowProxy is not defined, and its not equal to Window.
 * The postMessage method for them has different method argument.
 * WindowProxy have a second argument for targetOrigin, while other two's second argment is WindowPostMessageOption Object.
 * Therefore, we will want to know what the source type is so we can call the postMessage method correctly.
 */
function messageEventSourceIsWindow(source: MessageEventSource): source is Window {
  return (
    (!('MessagePort' in window) || !(source instanceof MessagePort)) &&
    (!('ServiceWorker' in window) || !(source instanceof ServiceWorker)) &&
    !!source.postMessage
  )
}

/** @internal */
export const attachIframeContextListener = (context: Context): void => {
  window.addEventListener('message', event => {
    if (!event.source || !messageFromSalsifyCDN(event)) {
      return
    }

    if (event.data.messageType !== messageTypes.contextRequest) {
      return
    }

    // If event has channel ports, post the messages back via the ports.
    if (event.ports.length) {
      event.ports.forEach(port => {
        port.postMessage(context)
      })
    } else if (messageEventSourceIsWindow(event.source)) {
      event.source.postMessage(context, event.origin)
    } else {
      event.source.postMessage(context)
    }
  })
}

/** @internal */
export interface EnhancedContentApiOptions {
  beforeRender?: (productId: string, idType?: string) => void
  afterRender?: (productId: string, idType?: string) => void
}

/** @internal */
export interface EcRenderConfig {
  productId: string
  idType: string
  content: PerProductConfig['content'] | null
  allContentExists: boolean
  source: string | null
  sourceExists: boolean
}

/**
 * The Enhanced Content API.
 *
 * This API is used to check for the existence of and to render Enhanced Content.
 */
export default class EnhancedContentApi {
  #perProductConfigCache: PerProductConfigCache
  #existsCache = new Map<string, boolean>()
  #lastRenderConfig?: EcRenderConfig
  #settings: SdkSettings
  #context: Context
  #logger: Logger
  #options?: EnhancedContentApiOptions

  /** @internal */
  public constructor(settings: SdkSettings, context: Context, logger: Logger, options?: EnhancedContentApiOptions) {
    this.#settings = settings
    this.#context = context
    this.#logger = logger
    this.#options = options
    this.#perProductConfigCache = new PerProductConfigCache(logger)
  }

  /**
   * Checks if Enhaned Content exists for the given combination of `productId` and `idType`
   *
   * This is an asynchronous request and uses a promise-based API. You must wait for the promise to resolve and use the
   * _resolved_ value to determine existence of EC. This can be done using async/await or a `then` callback.
   *
   * @example
   * Using `async/await`:
   * ```typescript
   * const ecExists = await salsify.enhancedContent.exists("123", "SKU");
   * if (ecExists) {
   *   // ...render EC
   * }
   * ```
   *
   * @example
   * Using a `then` callback:
   * ```typescript
   * salsify.enhancedContent.exists("123", "SKU").then((ecExists) => {
   *   if (ecExists) {
   *     // ...render EC
   *   }
   * });
   * ```
   *
   * @param productId The string ID for the product.
   * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}.
   * @returns `true` if the product has Enhanced Content to display, `false` otherwise
   */
  public async exists(productId: string, idType?: string): Promise<boolean> {
    idType = this.#checkAndGetIdType(idType)
    const cdnPath = this.#buildCdnPath(productId, idType)
    const config = await this.#perProductConfigCache.getConfig(cdnPath)
    const source = this.#getSource(config, cdnPath, productId)
    const content = config?.content ?? null
    const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath)
    const sourceExists = !!source && (await this.#checkExists(cdnPath, source))
    this.#lastRenderConfig = {
      idType,
      productId,
      content,
      allContentExists,
      source,
      sourceExists,
    }
    this.#logger.log('ec_exists', this.#lastRenderConfig)
    return sourceExists
  }

  /**
   * Renders Enhanced Content for the given `productId` and `idType` into an
   * iFrame that is inserted into the provided `container` element.
   *
   * This can only be called in a browser runtime context.
   *
   * The Salsify SDK is responsible for creating the `IFrame` element and synchronizing the
   * height based on content changes.
   *
   * @example
   * ```javascript
   * const salsify = window.salsifyExperiencesSdk;
   * const element = document.getElementById('enhanced-content-container');
   * salsify.enhancedContent.renderIframe(element, productId, idType);
   * ```
   *
   * @param container The DOM element within which to render Enhanced Content.
   * @param productId The string ID for the product.
   * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}.
   */
  public async renderIframe(container: HTMLElement, productId: string, idType?: string): Promise<void> {
    if (!inBrowser()) {
      throw new Error('Can only render EC iframe in a browser runtime context.')
    }

    if (this.#options?.beforeRender) {
      this.#options.beforeRender(productId, idType)
    }
    idType = this.#checkAndGetIdType(idType)
    const cdnPath = this.#buildCdnPath(productId, idType)
    const config = await this.#perProductConfigCache.getConfig(cdnPath)
    const source = this.#getSource(config, cdnPath, productId)
    if (source !== null) {
      attachIframeResizeListener(this.#logger)
      const iframe = this.#createIframe()
      iframe.src = this.#buildContentUrl(cdnPath, source)
      iframe.title = 'Salsify Enhanced Content'
      container.appendChild(iframe)
    }
    const content = config?.content ?? null
    const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath)
    const sourceExists = !!source && (await this.#checkExists(cdnPath, source))
    this.#lastRenderConfig = {
      idType,
      productId,
      content,
      allContentExists,
      source,
      sourceExists,
    }
    this.#logger.log('ec_render_iframe', this.#lastRenderConfig)
    this.#options?.afterRender?.(productId, idType)
  }

  /**
   * Updates the language code for subsequent Enhanced Content requests.
   *
   * This method can be used when a user updates the page language, and will
   * change the language of the content _without re-rendering_.
   *
   * To update the currently displayed content, the client application will
   * need to re-render the content after calling this method.
   *
   * @example
   * ```javascript
   * salsify.enhancedContent.updateLanguageCode('fr-CA');
   *
   * // this call now uses the new language code, "fr-CA"
   * salsify.enhancedContent.exists(productId, idType);
   * ```
   *
   * @param languageCode The language code to use for subsequent calls.
   */
  public updateLanguageCode(languageCode: string): void {
    this.#settings = { ...this.#settings, languageCode }
    this.#logger.log('ec_update_language_code', { languageCode })
  }

  /** @internal */
  public get lastRenderConfig(): EcRenderConfig | undefined {
    return this.#lastRenderConfig
  }

  #checkAndGetIdType(idType?: string): string {
    if (idType) {
      return idType
    }

    if (this.#settings.enhancedContent.idType) {
      return this.#settings.enhancedContent.idType
    }

    throw new Error('No ID type specified.')
  }

  #createIframe(): HTMLIFrameElement {
    const iframe: HTMLIFrameElement = document.createElement('iframe')

    iframe.id = iframeId
    iframe.height = '0'
    iframe.width = '100%'
    iframe.style.border = '0'
    iframe.scrolling = 'no'

    return iframe
  }

  #buildCdnOrigin(): string {
    if (this.#settings.cdnRoot && testEnvCdnPattern.test(this.#settings.cdnRoot)) {
      return this.#settings.cdnRoot
    } else if (this.#settings.staging) {
      return stagingCdnOrigin
    } else {
      return prodCdnOrigin
    }
  }

  #buildCdnPath(productId: string, idType: string): string {
    const cdnOrigin = this.#buildCdnOrigin()
    return `${cdnOrigin}/${cdnPrefix}/${this.#settings.clientId}/${this.#settings.languageCode}/BTF/${idType}/${productId}`
  }

  #buildContentUrl(cdnPath: string, source = defaultSource): string {
    return `${cdnPath}/${source}`
  }

  #getSource(config: PerProductConfig | undefined, cdnPath: string, productId: string): string | null {
    if (config && this.#settings.tracking) {
      const { content } = config
      const { sessionId } = this.#context
      if (sessionId) {
        const selectedSource = selectSource({ productId, content, sessionId })
        return selectedSource.source
      }
    }
    return defaultSource
  }

  async #checkExists(cdnPath: string, source: string): Promise<boolean> {
    const cacheKey = `${cdnPath}/${source}`
    if (!this.#existsCache.has(cacheKey)) {
      const contentUrl = this.#buildContentUrl(cdnPath, source)
      let response: Response
      try {
        response = await request.head(contentUrl)
      } catch (error) {
        const errorMessage = error instanceof Error ? error.message : undefined
        this.#logger.log('error', {
          errorContext: 'exists',
          errorType: 'fetch',
          errorMessage: `Error on HEAD request of ${contentUrl}: ${errorMessage}`,
        })
        return false
      }
      this.#existsCache.set(cacheKey, response.headers.get(Header.ContentLength) !== '0')
    }

    return this.#existsCache.get(cacheKey) || false
  }

  async #checkAllContentExists(content: PerProductConfig['content'] | undefined, cdnPath: string): Promise<boolean> {
    if (!content) {
      return false
    }
    let hasNonNullSource = false
    for (const src of content) {
      if (src.source) {
        hasNonNullSource = true
        const sourceExists = await this.#checkExists(cdnPath, src.source)
        if (!sourceExists) {
          return false
        }
      }
    }
    return hasNonNullSource
  }
}
