import { Logger } from '@refloat/logger'
import { createClient } from '@urql/core'
import { createReadStream } from 'fs'
import { join } from 'path'
import http from 'http'
import https from 'https'
import fetch from 'cross-fetch'

import {
	AcquireJobDocument,
	AcquireBuildDocument,
	AgentPlatform,
	ConfigureBuildDocument,
	RegisterDocument,
	RunConfigs,
	StartStepDocument,
	CompleteStepDocument,
	ConfigureBuildErrorDocument,
} from '../generated/schema'
import retry, { RetryOptions } from '../utils/retry'
import { defaultTags } from '../utils/tags'

const logger = Logger.create('api-client')

export class APIError extends Error {
	public readonly originalError: any

	constructor(error: any) {
		super(error.message)

		this.originalError = error
	}
}

const agents = {
	http: new http.Agent({ keepAlive: true }),
	https: new https.Agent({ keepAlive: true }),
}

export class APIClient {
	private token: string | null = null
	private readonly client = createClient({
		url: this.uri.href + 'gql',
		fetch: (input, { headers = {}, ...options } = {}) =>
			fetch(input, {
				...options,
				headers: {
					...headers,
					...this.headers(),
				},
				agent: (url: URL) =>
					url.protocol === 'https:' ? agents.https : agents.http,
			} as any),
	})

	constructor(public readonly uri: URL) {}

	public async register(agent: {
		token: string
		name: string
		platform: AgentPlatform
	}) {
		const {
			agent: { register },
		} = await this.retry('register-agent', () =>
			this.client
				.mutation(RegisterDocument, {
					...agent,
					tags: defaultTags,
				})
				.toPromise(),
		)

		this.token = register.token

		return register
	}

	public async acquireBuild() {
		const { agent } = await this.retry('acquire-build', () =>
			this.client.mutation(AcquireBuildDocument).toPromise(),
		)

		return agent.build.acquire ?? null
	}

	public async acquireJob() {
		const { agent } = await this.retry('acquire-job', () =>
			this.client.mutation(AcquireJobDocument).toPromise(),
		)

		return agent.job.acquire ?? null
	}

	public async configureBuild(
		build: string,
		sha: string,
		dependencies: string[],
		config: RunConfigs,
	) {
		await this.retry('configure-build', () =>
			this.client
				.mutation(ConfigureBuildDocument, { build, sha, dependencies, config })
				.toPromise(),
		)
	}

	public async configureBuildError(
		build: string,
		error: string,
		{
			notFound,
			sha,
		}: {
			notFound?: boolean
			sha?: string | null
		},
	) {
		await this.retry('configure-build-error', () =>
			this.client
				.mutation(ConfigureBuildErrorDocument, { build, error, notFound, sha })
				.toPromise(),
		)
	}

	public async start(stepId: string, date: Date) {
		await this.retry('start-step', () =>
			this.client
				.mutation(StartStepDocument, { id: stepId, date: date.toISOString() })
				.toPromise(),
		)
	}

	public async progress(stepId: string, buffer: Buffer) {
		await this.retry('update-step', async () => {
			const res = await fetch(
				this.uri.href + `api/agent/step/update/${stepId}`,
				{
					body: buffer,
					method: 'POST',
					headers: this.headers(),
				},
			)

			if (res.status !== 201) {
				throw new Error(`HTTP Error ${res.status}: ${res.statusText}`)
			}

			return { data: 'ok' }
		})
	}

	public async complete(stepId: string, date: Date, success: boolean) {
		await this.retry('complete-step', () =>
			this.client
				.mutation(CompleteStepDocument, {
					id: stepId,
					date: date.toISOString(),
					success,
				})
				.toPromise(),
		)
	}

	public async upload({
		stepId,
		cwd,
		path,
	}: {
		stepId: string
		cwd: string
		path: string
	}) {
		const res = await new Promise<Response>((resolve, reject) =>
			fetch(this.uri.href + `artifact/${stepId}/${encodeURIComponent(path)}`, {
				body: createReadStream(join(cwd, path)).on('error', reject) as any,
				method: 'PUT',
				headers: this.headers(),
			})
				.then(resolve)
				.catch(reject),
		)

		if (res.status < 200 || res.status >= 300) {
			console.error(await res.text())

			throw new APIError(`Failed to upload file: ${path}`)
		}
	}

	private async retry<T>(
		name: string,
		run: () => GQLPromise<{ data?: T; error?: unknown }>,
		options: RetryOptions = {},
	) {
		return retry(
			async (): Promise<T> => {
				const result: any = run()
				const promise =
					typeof result.toPromise === 'function' ? result.toPromise() : result
				const { data, error } = await promise

				if (error || !data) {
					throw new APIError(error)
				}

				return data
			},
			{
				onRetry: ({ error, attempt, delay }) =>
					logger.warn(`request error, retrying in ${delay / 1000}s..`, {
						request: name,
						attempt,
						error,
					}),
				shouldRetry: (error) => {
					const graphQLErrors =
						error?.originalError?.graphQLErrors ?? error?.graphQLErrors

					if (graphQLErrors?.length) {
						return graphQLErrors.some((err: any) => {
							const code = err?.extensions?.response?.statusCode

							return code && code >= 500
						})
					}

					return true
				},
				...options,
			},
		)
	}

	private headers() {
		return {
			Authorization: this.token ? `Bearer ${this.token}` : '',
		}
	}
}

type GQLPromise<T> = Promise<T> | { toPromise(): Promise<T> }
