import App from './app/index.js'

export { default as App } from './app/index.js'

import { UserAnonymousID, CustomEvent, CustomIssue } from './app/messages.gen.js'
import * as _Messages from './app/messages.gen.js'

export const Messages = _Messages
export { SanitizeLevel } from './app/sanitizer.js'

import Connection from './modules/connection.js'
import Console from './modules/console.js'
import Exception, {
  getExceptionMessageFromEvent,
  getExceptionMessage,
} from './modules/exception.js'
import Img from './modules/img.js'
import Input from './modules/input.js'
import Mouse from './modules/mouse.js'
import Timing from './modules/timing.js'
import Performance from './modules/performance.js'
import Scroll from './modules/scroll.js'
import Viewport from './modules/viewport.js'
import CSSRules from './modules/cssrules.js'
import Focus from './modules/focus.js'
import Fonts from './modules/fonts.js'
import Network from './modules/network.js'
import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
import Selection from './modules/selection.js'
import Tabs from './modules/tabs.js'

import { IN_BROWSER, deprecationWarn, DOCS_HOST, inIframe } from './utils.js'
import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js'
import type { Options as AppOptions } from './app/index.js'
import type { Options as ConsoleOptions } from './modules/console.js'
import type { Options as ExceptionOptions } from './modules/exception.js'
import type { Options as InputOptions } from './modules/input.js'
import type { Options as PerformanceOptions } from './modules/performance.js'
import type { Options as TimingOptions } from './modules/timing.js'
import type { Options as NetworkOptions } from './modules/network.js'
import type { MouseHandlerOptions } from './modules/mouse.js'
import type { SessionInfo } from './app/session.js'

import type { StartOptions } from './app/index.js'
//TODO: unique options init
import type { StartPromiseReturn } from './app/index.js'

export type Options = Partial<
  AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
> & {
  projectID?: number // For the back compatibility only (deprecated)
  projectKey: string
  sessionToken?: string
  respectDoNotTrack?: boolean
  autoResetOnWindowOpen?: boolean
  resetTabOnWindowOpen?: boolean
  network?: Partial<NetworkOptions>
  mouse?: Partial<MouseHandlerOptions>
  flags?: {
    onFlagsLoad?: (flags: IFeatureFlag[]) => void
  }
  // dev only
  __DISABLE_SECURE_MODE?: boolean
}

const DOCS_SETUP = '/en/sdk'

function processOptions(obj: any): obj is Options {
  if (obj == null) {
    console.error(
      `OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
    )
    return false
  }
  if (typeof obj.projectKey !== 'string') {
    if (typeof obj.projectKey !== 'number') {
      if (typeof obj.projectID !== 'number') {
        // Back compatability
        console.error(
          `OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`,
        )
        return false
      } else {
        obj.projectKey = obj.projectID.toString()
        deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP)
      }
    } else {
      console.warn('OpenReplay: projectKey is expected to have a string type.')
      obj.projectKey = obj.projectKey.toString()
    }
  }
  if (obj.sessionToken != null) {
    deprecationWarn('`sessionToken` option', '`sessionHash` start() option', '/')
  }
  return true
}

const canAccessTop = () => {
  try {
    return Boolean(window.top?.document)
  } catch {
    return false
  }
}

export default class API {
  public featureFlags: FeatureFlags

  private readonly app: App | null = null
  private readonly crossdomainMode: boolean = false

  constructor(public readonly options: Partial<Options>) {
    this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled)
    if (!IN_BROWSER || !processOptions(options)) {
      return
    }
    if (
      (window as any).__OPENREPLAY__ ||
      (!this.crossdomainMode && inIframe() && canAccessTop() && (window.top as any).__OPENREPLAY__)
    ) {
      console.error('OpenReplay: one tracker instance has been initialised already')
      return
    }
    if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') {
      console.error(
        'OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.',
      )
      return
    }
    const doNotTrack = this.checkDoNotTrack()
    const failReason: string[] = []
    const conditions: string[] = [
      'Map',
      'Set',
      'MutationObserver',
      'performance',
      'timing',
      'startsWith',
      'Blob',
      'Worker',
    ]

    if (doNotTrack) {
      failReason.push('doNotTrack')
    } else {
      for (const condition of conditions) {
        if (condition === 'timing') {
          if ('performance' in window && !(condition in performance)) {
            failReason.push(condition)
            break
          }
        } else if (condition === 'startsWith') {
          if (!(condition in String.prototype)) {
            failReason.push(condition)
            break
          }
        } else {
          if (!(condition in window)) {
            failReason.push(condition)
            break
          }
        }
      }
    }
    if (failReason.length > 0) {
      const missingApi = failReason.join(',')
      console.error(
        `OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1. Reason: ${missingApi}`,
      )
      this.signalStartIssue('missing_api', failReason)
      return
    }

    const app = new App(
      options.projectKey,
      options.sessionToken,
      options,
      this.signalStartIssue,
      this.crossdomainMode,
    )
    this.app = app
    if (!this.crossdomainMode) {
      // no need to send iframe viewport data since its a node for us
      Viewport(app)
      // calculated in main window
      Connection(app)
      // while we can calculate it here, trying to compute it for all parts is hard
      Performance(app, options)
      // no tabs in iframes yet
      Tabs(app)
    }
    Mouse(app, options.mouse)
    // inside iframe, we ignore viewport scroll
    Scroll(app, this.crossdomainMode)
    CSSRules(app)
    ConstructedStyleSheets(app)
    Console(app, options)
    Exception(app, options)
    Img(app)
    Input(app, options)
    Timing(app, options)
    Focus(app)
    Fonts(app)
    const skipNetwork = options.network?.disabled
    if (!skipNetwork) {
      Network(app, options.network)
    }
    Selection(app)
    ;(window as any).__OPENREPLAY__ = this

    if (options.flags && options.flags.onFlagsLoad) {
      this.onFlagsLoad(options.flags.onFlagsLoad)
    }
    const wOpen = window.open
    if (options.autoResetOnWindowOpen || options.resetTabOnWindowOpen) {
      app.attachStartCallback(() => {
        const tabId = app.getTabId()
        const sessStorage = app.sessionStorage ?? window.sessionStorage
        window.open = function (...args) {
          if (options.autoResetOnWindowOpen) {
            app.resetNextPageSession(true)
          }
          if (options.resetTabOnWindowOpen) {
            sessStorage.removeItem(options.session_tabid_key || '__openreplay_tabid')
          }
          app.resetNextPageSession(false)
          sessStorage.setItem(options.session_tabid_key || '__openreplay_tabid', tabId)
          return wOpen.call(window, ...args)
        }
      })
      app.attachStopCallback(() => {
        window.open = wOpen
      })
    }
  }

  checkDoNotTrack = () => {
    return (
      this.options.respectDoNotTrack &&
      (navigator.doNotTrack == '1' ||
        // @ts-ignore
        window.doNotTrack == '1')
    )
  }

  signalStartIssue = (reason: string, missingApi: string[]) => {
    const doNotTrack = this.checkDoNotTrack()
    console.log(
      "Tracker couldn't start due to:",
      JSON.stringify({
        trackerVersion: 'TRACKER_VERSION',
        projectKey: this.options.projectKey,
        doNotTrack,
        reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
      }),
    )
  }

  isFlagEnabled(flagName: string): boolean {
    return this.featureFlags.isFlagEnabled(flagName)
  }

  onFlagsLoad(callback: (flags: IFeatureFlag[]) => void): void {
    this.app?.featureFlags.onFlagsLoad(callback)
  }

  clearPersistFlags() {
    this.app?.featureFlags.clearPersistFlags()
  }

  reloadFlags() {
    return this.app?.featureFlags.reloadFlags()
  }

  getFeatureFlag(flagName: string): IFeatureFlag | undefined {
    return this.app?.featureFlags.getFeatureFlag(flagName)
  }

  getAllFeatureFlags() {
    return this.app?.featureFlags.flags
  }

  public restartCanvasTracking = () => {
    if (this.app === null) {
      return
    }
    this.app.restartCanvasTracking()
  }

  use<T>(fn: (app: App | null, options?: Partial<Options>) => T): T {
    return fn(this.app, this.options)
  }

  isActive(): boolean {
    if (this.app === null) {
      return false
    }
    return this.app.active()
  }

  /**
   * Creates a named hook that expects event name, data string and msg direction (up/down),
   * it will skip any message bigger than 5 mb or event name bigger than 255 symbols
   * msg direction is "down" (incoming) by default
   *
   * @returns {(msgType: string, data: string, dir: 'up' | 'down') => void}
   * */
  trackWs(channelName: string) {
    if (this.app === null) {
      return
    }
    return this.app.trackWs(channelName)
  }

  start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
    if (this.browserEnvCheck()) {
      if (this.app === null) {
        return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
      }
      return this.app.start(startOpts)
    } else {
      return Promise.reject('Trying to start not in browser.')
    }
  }

  browserEnvCheck() {
    if (!IN_BROWSER) {
      console.error(
        `OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
      )
      return false
    }
    return true
  }

  /**
   * start buffering messages without starting the actual session, which gives user 30 seconds to "activate" and record
   * session by calling start() on conditional trigger and we will then send buffered batch, so it won't get lost
   * */
  coldStart(startOpts?: Partial<StartOptions>, conditional?: boolean) {
    if (this.browserEnvCheck()) {
      if (this.app === null) {
        return Promise.reject('Tracker not initialized')
      }
      void this.app.coldStart(startOpts, conditional)
    } else {
      return Promise.reject('Trying to start not in browser.')
    }
  }

  /**
   * Starts offline session recording. Keep in mind that only user device time will be used for timestamps.
   * (no backend delay sync)
   *
   * @param {Object} startOpts - options for session start, same as .start()
   * @param {Function} onSessionSent - callback that will be called once session is fully sent
   * @returns methods to manipulate buffer:
   *
   * saveBuffer - to save it in localStorage
   *
   * getBuffer - returns current buffer
   *
   * setBuffer - replaces current buffer with given
   * */
  startOfflineRecording(startOpts: Partial<StartOptions>, onSessionSent: () => void) {
    if (this.browserEnvCheck()) {
      if (this.app === null) {
        return Promise.reject('Tracker not initialized')
      }
      return this.app.offlineRecording(startOpts, onSessionSent)
    } else {
      return Promise.reject('Trying to start not in browser.')
    }
  }

  /**
   * Uploads the stored session buffer to backend
   * @returns promise that resolves once messages are loaded, it has to be awaited
   * so the session can be uploaded properly
   * @resolve - if messages were loaded into service worker successfully
   * @reject {string} - error message
   * */
  uploadOfflineRecording() {
    if (this.app === null) {
      return
    }
    return this.app.uploadOfflineRecording()
  }

  stop(): string | undefined {
    if (this.app === null) {
      return
    }
    this.app.stop()
    return this.app.session.getSessionHash()
  }

  forceFlushBatch() {
    if (this.app === null) {
      return
    }
    this.app.forceFlushBatch()
  }

  getSessionToken(): string | null | undefined {
    if (this.app === null) {
      return null
    }
    return this.app.getSessionToken()
  }

  getSessionInfo(): SessionInfo | null {
    if (this.app === null) {
      return null
    }
    return this.app.session.getInfo()
  }

  getSessionID(): string | null | undefined {
    if (this.app === null) {
      return null
    }
    return this.app.getSessionID()
  }

  getTabId() {
    if (this.app === null) {
      return null
    }
    return this.app.getTabId()
  }

  getUxId() {
    if (this.app === null) {
      return null
    }
    return this.app.getUxtId()
  }

  sessionID(): string | null | undefined {
    deprecationWarn("'sessionID' method", "'getSessionID' method", '/')
    return this.getSessionID()
  }

  getSessionURL(options?: { withCurrentTime?: boolean }): string | undefined {
    if (this.app === null) {
      return undefined
    }
    return this.app.getSessionURL(options)
  }

  setUserID(id: string): void {
    if (typeof id === 'string' && this.app !== null) {
      this.app.session.setUserID(id)
    }
  }

  userID(id: string): void {
    deprecationWarn("'userID' method", "'setUserID' method", '/')
    this.setUserID(id)
  }

  setUserAnonymousID(id: string): void {
    if (typeof id === 'string' && this.app !== null) {
      this.app.send(UserAnonymousID(id))
    }
  }

  userAnonymousID(id: string): void {
    deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/')
    this.setUserAnonymousID(id)
  }

  setMetadata(key: string, value: string): void {
    if (typeof key === 'string' && typeof value === 'string' && this.app !== null) {
      this.app.session.setMetadata(key, value)
    }
  }

  metadata(key: string, value: string): void {
    deprecationWarn("'metadata' method", "'setMetadata' method", '/')
    this.setMetadata(key, value)
  }

  event(key: string, payload: any = null, issue = false): void {
    if (typeof key === 'string' && this.app !== null) {
      if (issue) {
        return this.issue(key, payload)
      } else {
        try {
          payload = JSON.stringify(payload)
        } catch (e) {
          return
        }
        this.app.send(CustomEvent(key, payload))
      }
    }
  }

  issue(key: string, payload: any = null): void {
    if (typeof key === 'string' && this.app !== null) {
      try {
        payload = JSON.stringify(payload)
      } catch (e) {
        return
      }
      this.app.send(CustomIssue(key, payload))
    }
  }

  handleError = (
    e: Error | ErrorEvent | PromiseRejectionEvent,
    metadata: Record<string, any> = {},
  ) => {
    if (this.app === null) {
      return
    }
    if (e instanceof Error) {
      const msg = getExceptionMessage(e, [], metadata)
      this.app.send(msg)
    } else if (
      e instanceof ErrorEvent ||
      ('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
    ) {
      const msg = getExceptionMessageFromEvent(e, undefined, metadata)
      if (msg != null) {
        this.app.send(msg)
      }
    }
  }
}
