import axios from 'axios'
import { isBrowser } from 'browser-or-node'
import * as consts from './consts'
import * as errors from './errors'
import { ServerEventsProtocol } from './eventsource'
import { apiVersion, Client as AutoGeneratedClient } from './gen/client'
import jwt from './jsonwebtoken'
import { AsyncCollection } from './listing'
import { SignalListener } from './signal-listener'
import * as types from './types'

const _100mb = 100 * 1024 * 1024
const maxBodyLength = _100mb
const maxContentLength = _100mb
const defaultTimeout = 60_000

const _createAuthClient = Symbol('_createAuthClient')

type Merge<A, B> = Omit<A, keyof B> & B
type IClient = Merge<
  {
    [K in types.ClientOperation]: (x: types.ClientRequests[K]) => Promise<types.ClientResponses[K]>
  },
  {
    listenConversation: (
      args: types.ClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
    ) => Promise<SignalListener>
  }
>

type IAuthenticatedClient = Merge<
  {
    [K in types.AuthenticatedOperation]: (x: types.AuthenticatedClientRequests[K]) => Promise<types.ClientResponses[K]>
  },
  {
    listenConversation: (
      args: types.AuthenticatedClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
    ) => Promise<SignalListener>
  }
>

export class Client implements IClient {
  private _connectionTested = false
  private _auto: AutoGeneratedClient

  public constructor(public readonly props: Readonly<types.ClientProps>) {
    const axiosClient = Client._createAxios(props)
    this._auto = new AutoGeneratedClient(axiosClient)
  }

  public get apiVersion() {
    return apiVersion
  }

  /**
   * Gets or creates a user based on the provided props and returns an authenticated client.
   */
  public static async connect(props: types.ConnectProps): Promise<AuthenticatedClient> {
    const { userId, userKey, encryptionKey, ...clientProps } = props
    const client = new Client(clientProps)
    await client._testConnection()

    if (userKey) {
      const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
      return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
    }

    if (encryptionKey) {
      if (!jwt) {
        const message =
          'Connecting with an encryption key is not supported in the browser; use in NodeJs or format the key manually with jsonwebtoken.'
        throw new errors.ChatConfigError(message)
      }

      if (!userId) {
        throw new errors.ChatConfigError(
          'userId is required when connecting with an encryption key. You may pick any userId of your choice that is not already taken by another user.'
        )
      }

      const userKey = jwt.sign({ id: userId }, encryptionKey, { algorithm: 'HS256' })
      const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
      return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
    }

    const { user, key } = await client.createUser({ id: userId })
    return AuthenticatedClient[_createAuthClient](client, { ...user, key })
  }

  public readonly createConversation: IClient['createConversation'] = (x) => this._call('createConversation', x)
  public readonly getConversation: IClient['getConversation'] = (x) => this._call('getConversation', x)
  public readonly getOrCreateConversation: IClient['getOrCreateConversation'] = (x) =>
    this._call('getOrCreateConversation', x)
  public readonly deleteConversation: IClient['deleteConversation'] = (x) => this._call('deleteConversation', x)
  public readonly listConversations: IClient['listConversations'] = (x) => this._call('listConversations', x)
  public readonly listMessages: IClient['listMessages'] = (x) => this._call('listMessages', x)
  public readonly addParticipant: IClient['addParticipant'] = (x) => this._call('addParticipant', x)
  public readonly removeParticipant: IClient['removeParticipant'] = (x) => this._call('removeParticipant', x)
  public readonly getParticipant: IClient['getParticipant'] = (x) => this._call('getParticipant', x)
  public readonly listParticipants: IClient['listParticipants'] = (x) => this._call('listParticipants', x)
  public readonly createMessage: IClient['createMessage'] = (x) => this._call('createMessage', x)
  public readonly getMessage: IClient['getMessage'] = (x) => this._call('getMessage', x)
  public readonly deleteMessage: IClient['deleteMessage'] = (x) => this._call('deleteMessage', x)
  public readonly createUser: IClient['createUser'] = (x) => this._call('createUser', x)
  public readonly getUser: IClient['getUser'] = (x) => this._call('getUser', x)
  public readonly getOrCreateUser: IClient['getOrCreateUser'] = (x) => this._call('getOrCreateUser', x)
  public readonly updateUser: IClient['updateUser'] = (x) => this._call('updateUser', x)
  public readonly deleteUser: IClient['deleteUser'] = (x) => this._call('deleteUser', x)
  public readonly createEvent: IClient['createEvent'] = (x) => this._call('createEvent', x)
  public readonly getEvent: IClient['getEvent'] = (x) => this._call('getEvent', x)

  public get list() {
    return {
      conversations: (props: types.ClientRequests['listConversations']) =>
        new AsyncCollection(({ nextToken }) =>
          this.listConversations({ nextToken, ...props }).then((r) => ({ ...r, items: r.conversations }))
        ),
      messages: (props: types.ClientRequests['listMessages']) =>
        new AsyncCollection(({ nextToken }) =>
          this.listMessages({ nextToken, ...props }).then((r) => ({ ...r, items: r.messages }))
        ),
      participants: (props: types.ClientRequests['listParticipants']) =>
        new AsyncCollection(({ nextToken }) =>
          this.listParticipants({ nextToken, ...props }).then((r) => ({ ...r, items: r.participants }))
        ),
    }
  }

  public readonly listenConversation: IClient['listenConversation'] = async ({
    id,
    'x-user-key': userKey,
    protocol,
  }) => {
    const signalListener = await SignalListener.listen({
      url: this._apiUrl,
      conversationId: id,
      userKey,
      debug: this.props.debug ?? false,
      protocol: protocol ?? 'sse',
    })
    return signalListener
  }

  private _call = async (operation: types.ClientOperation, args: any): Promise<any> => {
    try {
      await this._testConnection()
      const response = await this._auto[operation](args)
      const res = this._checkPayloadForError(response)
      return res
    } catch (thrown) {
      if (errors.isApiError(thrown)) {
        throw thrown
      }
      throw errors.ChatClientError.map(thrown)
    }
  }

  /**
   * The Chat-API is called like any other integrations by sending requests to the bridge webhook endpoint.
   * This endpoint may return a successful status code even when the payload contains an error.
   * This method parses the payload to check for an error and throws an error if one is found.
   */
  private _checkPayloadForError = <T>(response: unknown): T => {
    if (typeof response !== 'object' || response === null) {
      return response as T
    }

    if (!('code' in response)) {
      return response as T
    }

    const { code } = response
    if (typeof code !== 'number') {
      return response as T
    }

    if (code < 400 || code >= 600) {
      return response as T
    }

    const message = 'message' in response ? String(response['message']) : 'An error occurred'
    throw new errors.ChatHTTPError(code, message)
  }

  private static _createAxios = (props: types.ClientProps) => {
    const headers: types.Headers = {
      ...props.headers,
    }
    const timeout = props.timeout ?? defaultTimeout
    const withCredentials = isBrowser
    const baseURL = this._getApiUrl(props)
    return axios.create({
      baseURL,
      headers,
      withCredentials,
      timeout,
      maxBodyLength,
      maxContentLength,
      validateStatus: (status) => status >= 200 && status < 400,
    })
  }

  private get _apiUrl() {
    return Client._getApiUrl(this.props)
  }

  private static _getApiUrl = (props: types.ClientProps) => {
    if ('apiUrl' in props) {
      return props.apiUrl
    }

    const baseApiUrl = props.baseApiUrl ?? consts.defaultBaseApiUrl
    const { webhookId } = props
    return `${baseApiUrl}/${webhookId}`
  }

  private _testConnection = async () => {
    if (this._connectionTested) {
      return
    }

    const url = `${this._apiUrl}/hello`
    const axiosInstance = axios.create({ baseURL: url })
    try {
      const response = await axiosInstance.get('/')
      this._checkPayloadForError(response.data)
    } catch (thrown) {
      throw errors.ChatClientError.wrap(thrown, `Failed to connect to url "${this._apiUrl}"`)
    }

    this._connectionTested = true
  }
}

export class AuthenticatedClient implements IAuthenticatedClient {
  private constructor(
    private _client: Client,
    public readonly user: types.AuthenticatedUser
  ) {}

  // can not be instantiated outside of this module
  public static [_createAuthClient] = (client: Client, user: types.AuthenticatedUser) => {
    return new AuthenticatedClient(client, user)
  }

  public get apiVersion() {
    return this._client.apiVersion
  }

  public readonly createConversation: IAuthenticatedClient['createConversation'] = (x) =>
    this._client.createConversation({ 'x-user-key': this.user.key, ...x })
  public readonly getConversation: IAuthenticatedClient['getConversation'] = (x) =>
    this._client.getConversation({ 'x-user-key': this.user.key, ...x })
  public readonly getOrCreateConversation: IAuthenticatedClient['getOrCreateConversation'] = (x) =>
    this._client.getOrCreateConversation({ 'x-user-key': this.user.key, ...x })
  public readonly deleteConversation: IAuthenticatedClient['deleteConversation'] = (x) =>
    this._client.deleteConversation({ 'x-user-key': this.user.key, ...x })
  public readonly listConversations: IAuthenticatedClient['listConversations'] = (x) =>
    this._client.listConversations({ 'x-user-key': this.user.key, ...x })
  public readonly listMessages: IAuthenticatedClient['listMessages'] = (x) =>
    this._client.listMessages({ 'x-user-key': this.user.key, ...x })
  public readonly listenConversation: IAuthenticatedClient['listenConversation'] = (x) =>
    this._client.listenConversation({ 'x-user-key': this.user.key, ...x })
  public readonly addParticipant: IAuthenticatedClient['addParticipant'] = (x) =>
    this._client.addParticipant({ 'x-user-key': this.user.key, ...x })
  public readonly removeParticipant: IAuthenticatedClient['removeParticipant'] = (x) =>
    this._client.removeParticipant({ 'x-user-key': this.user.key, ...x })
  public readonly getParticipant: IAuthenticatedClient['getParticipant'] = (x) =>
    this._client.getParticipant({ 'x-user-key': this.user.key, ...x })
  public readonly listParticipants: IAuthenticatedClient['listParticipants'] = (x) =>
    this._client.listParticipants({ 'x-user-key': this.user.key, ...x })
  public readonly createMessage: IAuthenticatedClient['createMessage'] = (x) =>
    this._client.createMessage({ 'x-user-key': this.user.key, ...x })
  public readonly getMessage: IAuthenticatedClient['getMessage'] = (x) =>
    this._client.getMessage({ 'x-user-key': this.user.key, ...x })
  public readonly deleteMessage: IAuthenticatedClient['deleteMessage'] = (x) =>
    this._client.deleteMessage({ 'x-user-key': this.user.key, ...x })
  public readonly getUser: IAuthenticatedClient['getUser'] = (x) =>
    this._client.getUser({ 'x-user-key': this.user.key, ...x })
  public readonly updateUser: IAuthenticatedClient['updateUser'] = (x) =>
    this._client.updateUser({ 'x-user-key': this.user.key, ...x })
  public readonly deleteUser: IAuthenticatedClient['deleteUser'] = (x) =>
    this._client.deleteUser({ 'x-user-key': this.user.key, ...x })
  public readonly createEvent: IAuthenticatedClient['createEvent'] = (x) =>
    this._client.createEvent({ 'x-user-key': this.user.key, ...x })
  public readonly getEvent: IAuthenticatedClient['getEvent'] = (x) =>
    this._client.getEvent({ 'x-user-key': this.user.key, ...x })

  public get list() {
    return {
      conversations: (x: types.AuthenticatedClientRequests['listConversations']) =>
        this._client.list.conversations({ 'x-user-key': this.user.key, ...x }),
      messages: (x: types.AuthenticatedClientRequests['listMessages']) =>
        this._client.list.messages({ 'x-user-key': this.user.key, ...x }),
      participants: (x: types.AuthenticatedClientRequests['listParticipants']) =>
        this._client.list.participants({ 'x-user-key': this.user.key, ...x }),
    }
  }
}
