import jwtDecode from 'jwt-decode'
import queryString from 'query-string'
import { AxiosError, AxiosInstance } from 'axios'
import { AxiosCacheInstance, CacheOptions } from 'axios-cache-interceptor'

import { EventService, MyEventsEvent } from './event'
import { OrderService } from './order'
import { TicketService } from './ticket'
import { AccountService } from './account'
import { PaymentService } from './payment'
import { BlockchainService } from './blockchain'
import { ValidatorService } from './validator'

import {
	BaseUrl,
	buildClient,
	createBaseUrl,
	IClientBuildOptions,
	setAxiosUpdateTokenFunction,
} from './common'
import { ImportData, ImportDataRequest, MyEventsQuery } from '.'
import { AuthService } from './auth'
import { VenueService } from './venue'
import { ClusterHealth } from './common/types'
import { Jwt, RefreshableJwt } from './auth/types'
import { CredentialFactory } from './credential/credential-factory'
import { ICredential, GuestCredentials, ICredentialData } from './credential'
import { UploadService } from './upload/service'
import { OrderbookService } from './orderbook'
import { NftService } from './nft'
import { NotificationService } from './notification'
import { PdfService } from './pdf/service'
import { BAMgregatorService } from './bamgregator'

export type ApiVersion = 'v1' // | 'v2' ... some day

export interface RetryOptions {
	retries?: number
	retryCondition?: (error: AxiosError<any>) => boolean | Promise<boolean>
	shouldResetTimeout?: boolean
	retryDelay?: (retryCount: number, error: any) => number
}

export interface BamOptions {
	baseUrl: string | BaseUrl
	version?: ApiVersion
	organizer?: string
	credentials?: ICredential | ICredentialData
	retry?: RetryOptions
	cache?: CacheOptions
	camelCaseResponse?: boolean
}

/**
 * BAM services class. The BAM instance holds the API URLs, the authorization token
 * and keeps track of the API version used.
 */
export class BAM {
	// Services
	auth: AuthService
	account: AccountService
	venue: VenueService
	upload: UploadService
	validator: ValidatorService
	nft: NftService
	pdf: PdfService
	bamgregator: BAMgregatorService

	event: EventService
	order: OrderService
	payment: PaymentService
	blockchain: BlockchainService
	ticket: TicketService
	orderbook: OrderbookService
	notification: NotificationService

	client: AxiosInstance | AxiosCacheInstance
	tenantClient: AxiosInstance | AxiosCacheInstance

	// User/service/guest credentials
	private _credentials?: ICredential
	private _tenant?: string
	private _organizer?: string
	private _deviceId?: string

	constructor(
		readonly baseUrl: string | BaseUrl,
		readonly version: ApiVersion = 'v1',
		retryOptions?: RetryOptions,
		cacheOptions?: CacheOptions,
		camelCaseResponse = true
	) {
		let orgDefault: string
		switch (baseUrl) {
			case BaseUrl.Dev:
				orgDefault = 'org1'
				break
			case BaseUrl.Prod:
				orgDefault = 'catapult'
				break
			case BaseUrl.QA:
				orgDefault = 'qa'
				break
			case BaseUrl.ThProd:
			case BaseUrl.ThStaging:
				orgDefault = 'tickethead'
				break
		}

		const options: IClientBuildOptions = {
			baseUrl,
			retryOptions,
			cacheOptions,
			camelCaseResponse,
		}
		this.client = buildClient(options)
		this.tenantClient = buildClient({
			...options,
			tenantName: orgDefault,
		})

		// Global services
		this.auth = new AuthService(this.client, version)
		this.account = new AccountService(this.client, version)
		this.venue = new VenueService(this.client, version)
		this.upload = new UploadService(this.client, version)
		this.validator = new ValidatorService(this.client, version)
		this.nft = new NftService(this.client, version)
		this.pdf = new PdfService(this.client, version)
		this.bamgregator = new BAMgregatorService(this.client, version)

		// Per-tenant services
		this.event = new EventService(this.tenantClient, version)
		this.order = new OrderService(this.tenantClient, version)
		this.payment = new PaymentService(this.tenantClient, version)
		this.blockchain = new BlockchainService(this.tenantClient, version)
		this.ticket = new TicketService(this.tenantClient, version)
		this.orderbook = new OrderbookService(this.tenantClient, version)

		// Hybrids
		this.notification = new NotificationService(
			this.client,
			this.tenantClient,
			version
		)
	}

	public get credentials() {
		return this._credentials
	}

	public get tenant(): string {
		return this._tenant
	}

	/**
	 * Sets the tenant used by tenant-specific services.
	 *
	 * @param tenantName Name of a tenant to be set
	 */
	public set tenant(tenantName: string) {
		this._tenant = tenantName
		// Organizer name is reset to tenant name, as in the general case it no longer corresponds
		this._organizer = tenantName
		this.tenantClient.defaults.baseURL = createBaseUrl(this.baseUrl, tenantName)
	}

	public get organizer(): string {
		return this._organizer
	}

	public get deviceId() {
		return this._deviceId
	}

	public set deviceId(deviceId: string) {
		this._deviceId = deviceId
		this.client.defaults.headers['x-device-id'] = deviceId
		this.tenantClient.defaults.headers['x-device-id'] = deviceId
	}

	/**
	 * You can use the object in the response to construct JwtCredentials.
	 */
	public getToken(): Jwt | RefreshableJwt {
		return this.credentials?.getToken()
	}

	public static getTokenPayload(token: string): Record<string, any> {
		return jwtDecode(token)
	}

	/**
	 * Used to store the state of the SDK.
	 * Can be used via the build method to restore state.
	 */
	serialize(): BamOptions {
		return {
			version: this.version,
			baseUrl: this.baseUrl,
			organizer: this.organizer,
			credentials: this.credentials,
		}
	}

	/**
	 * Factory method for initializing the SDK.
	 * Can be used in conjunction with the serialize method to restore state.
	 */
	static async build(params: BamOptions): Promise<BAM> {
		const {
			baseUrl,
			version = 'v1',
			organizer,
			retry,
			cache,
			camelCaseResponse,
		} = params
		const bam = new BAM(baseUrl, version, retry, cache, camelCaseResponse)

		if (params.organizer) await bam.useOrganizer(organizer)

		if (params.credentials) {
			const creds = isCredential(params.credentials)
				? params.credentials
				: CredentialFactory.build(params.credentials)

			await bam.authorize(creds)
		}

		return bam
	}

	/**
	 * Sets the organizer used on the instance. Any event, payment, reporting or websocket API call
	 * will be made to the organizer's tenant API.
	 *
	 * @param organizerName Name of an organizer to be set on the instance
	 */
	async useOrganizer(organizerName: string): Promise<void> {
		if (organizerName === this._organizer) {
			// No need to refresh the token if it is for the selected organizer
			return
		}
		const organizer = await this.account.getOrganizer({ id: organizerName })
		// Second term is to support the no case conversion use case
		this.tenant =
			organizer.organizationName || (organizer as any).organization_name
		// Ordering is important as using the tenant setter resets the organizer name
		this._organizer = organizer.name

		// Refresh the token, fetching the permissions for the specified organizer
		// For guest and service tokens, this does not change anything
		await this.authorize(this._credentials, this._organizer)
	}

	/**
	 * Authorizes the SDK instance with a JWT obtained from the API.
	 * For user or organizer based login supply `PasswordCredentials`.
	 * For internal service authentication use `ServiceCredentials` with the appropriate organizer ID.
	 * For logging in with a wallet, use `WalletCredentials` aka the private key and certificate.
	 * For guest login use the parameterless version of `authorize`.
	 *
	 * @param credentials User, organizer, service or wallet credentials. In case of `undefined`, log in as guest.
	 * @param organizer The organizer name of the organizer for which the token is requested.
	 */
	async authorize(
		credentials: ICredential = new GuestCredentials(),
		organizer?: string
	) {
		this._credentials = credentials
		const jwt = await credentials.authorize(this.auth, organizer)
		// Set the auth headers
		this.client.defaults.headers['Authorization'] = `Bearer ${jwt.token}`
		this.tenantClient.defaults.headers['Authorization'] = `Bearer ${jwt.token}`

		// This is an instance of jjs programming
		const refreshFunction = async () => {
			const newJwt = await this.credentials.refreshToken(this.auth, organizer)
			return newJwt.token
		}
		setAxiosUpdateTokenFunction(this.client, refreshFunction)
		setAxiosUpdateTokenFunction(this.tenantClient, refreshFunction)

		return jwt
	}

	/**
	 * Sets the Bearer header of the calls to the specified token.
	 * You can also use the authorize method with JwtCredentials.
	 *
	 * @param jwt JWT to use for calls to the BAM services
	 */
	setAuthorization(jwt: Jwt) {
		// Set the auth headers
		this.client.defaults.headers['Authorization'] = `Bearer ${jwt.token}`
		this.tenantClient.defaults.headers['Authorization'] = `Bearer ${jwt.token}`
	}

	/**
	 * Returns the status of currently used BAM services.
	 * This includes global services and the services for the current tenant.
	 */
	async health(): Promise<ClusterHealth> {
		const accHealth = this.account.health()
		const eventHealth = this.event.health()
		const venueHealth = this.venue.health()
		const paymentHealth = this.payment.health()
		const blockchainHealth = this.blockchain.health()
		// Await at the same time so its parallel
		const [account, event, payment, blockchain, venue] = await Promise.all([
			accHealth,
			eventHealth,
			paymentHealth,
			blockchainHealth,
			venueHealth,
		])

		// This needs to be changed if a service is extracted from another service
		// This is mapped so there are no redundant calls to services
		return {
			auth: account,
			account: account,
			payment: payment,
			event: event,
			order: event,
			blockchain: blockchain,
			venue: venue,
		}
	}

	/**
	 * Returns events with secure tickets for the authorized enrolled user.
	 *
	 * @param req.date Filter events by `end_at` date comparing with `midnight`. Expected values are `future`, `past` and `all`.
	 * @returns
	 */
	async getMyEvents(req: MyEventsQuery): Promise<MyEventsEvent[]> {
		const query = queryString.stringify({
			date: req.date,
		})

		const res = await this.client.get(`${this.version}/my_events?${query}`)

		return res.data.data
	}

	/**
	 * Returns data that needs to be imported to the local validation server.
	 */
	async getImportData(request: ImportDataRequest): Promise<ImportData> {
		const event = await this.event.getEvent({
			id: request.eventId,
			organizer_id: request.organizerId,
		})

		const [venue, qrCodes, validators] = await Promise.all([
			this.venue.getVenue({
				id: parseInt(event.venueId),
			}),
			this.event.getQrCodes({
				id: request.eventId,
				organizer_id: request.organizerId,
			}),
			this.validator.getExportedValidators(
				{ id: request.organizerId },
				{
					event_id: request.eventId,
				}
			),
		])

		const importedValidators = validators.map((v) => {
			// Casing issue regarding the blockchain service...
			const wallet = {
				...v.wallet,
				private_key: v.wallet.privateKey,
				privateKey: undefined as string,
			}

			return { ...v, wallet }
		})

		return {
			event: {
				event,
				qr_codes: qrCodes,
			},
			venue: {
				venue,
			},
			account: {
				validators: importedValidators,
			},
		}
	}

	/**
	 * Enable sending the current version to the backend and checking against the configured minimum version
	 *
	 * @param version Version which is sent to the backend and checked against the minimum version. Format 'X.Y.Z'
	 */
	enableMinimumVersionCheck(version: string) {
		// set x-app-version headers
		this.client.defaults.headers['x-app-version'] = version
		this.tenantClient.defaults.headers['x-app-version'] = version
	}
}

function isCredential(
	credential: ICredential | ICredentialData
): credential is ICredential {
	return (<ICredential>credential)?.authorize !== undefined
}
