import { CancelTokenSource, AxiosResponse, AxiosRequestConfig } from 'axios';
import { Client, Sink, createClient } from 'graphql-ws';
import { DocumentNode, print, parse } from "graphql";
import { composite } from '~/src/composite';
import { buildPath, logger } from '~/src/helpers';
import { Context, Plugin } from '@nuxt/types';
import { NuxtAxiosInstance } from '~/typings/axios';

const plugin: Plugin = (context, inject) => {
	inject('api', new APIService(context));
};

export default plugin;

/**
 * The service in charge of connecting to the Vindigo Server
 * and handle all GraphQL communications.
 */
export class APIService {

	private fragments = require.context('~/assets/query/_fragments', true, /\.gql$/);
	private logger = logger('API', true);
	private uploadPath = '/upload';
	private endpoint = '/graphql';
	private axios: NuxtAxiosInstance;
	private client?: Client;

	public constructor(context: Context) {
		this.axios = context.$axios;

		if(process.client) {
			this.connect();
		}
	}

	/**
	 * Returns whether the client is connected
	 */
	public get isConnected(): boolean {
		return !!this.client;
	
	}

	/**
	 * Upload the given file to the server as the
	 * specified type.
	 * 
	 * @param type The type of the file
	 * @param file The file binary data
	 * @param config Further upload configuration
	 * @returns The response
	 */
	public async upload(type: string, file: File, config?: QueryConfig): Promise<AxiosResponse<any>> {
		const endpoint = config?.endpoint ?? this.uploadPath;
		const url = endpoint + '/' + type;
		const formData = new FormData();

		formData.append('file', file);

		const response = await this.axios.post(url, formData, {
			cancelToken: config?.cancel?.token,
			...config?.options
		});

		if(response.status != 200) {
			throw new Error('Upload failure: ' + (response.statusText));
		}

		return response;
	}

	/**
	 * Executes the given query and returns the result
	 *
	 * @param source The query to execute
	 * @param variables The query variables
	 * @param config Further query configuration
	 * @returns Result
	 */
	public async query(source: DocumentNode|string, variables?: any, config?: QueryConfig) : Promise<any> {
		const query = this.parseQuery(source);
		const endpoint = config?.endpoint ?? this.endpoint;
		const headers = {
			'Content-Type': 'application/json',
			'Accept': 'application/json',
		};

		let response: any;

		try {
			response = await this.axios.$post(endpoint, {
				query: print(query),
				variables: variables || {},
				operationName: config?.operation
			}, {
				cancelToken: config?.cancel?.token,
				headers: headers,
				progress: false,
				...config?.options
			});
		} catch(err) {
			response = err.response.data;
		}

		if(response.errors) {
			throw new ServerApiError(response);
		} else {
			return response.data || {};
		}
	}

	/**
	 * Executes the given query and listens to the responses.
	 *
	 * @param source The query to execute
	 * @param observer The response observer
	 * @param variables The query variables
	 * @param config Further query configuration
	 * @returns Subscription disposer
	 */
	public subscribe(options: SubscriptionOptions) : () => void {
		if(!this.client) {
			throw new Error('Connection not established');
		} else if(!process.client) {
			return () => undefined;
		}

		const query = this.parseQuery(options.query);

		this.logger('Executing subscription:');
		console.debug(print(query));

		return this.client.subscribe({
			query: print(query),
			variables: options.variables,
			operationName: options.config?.operation
		}, {
			next(res: any) {
				if(res.error || res.errors) {
					const err = new Error('Query failed: ' + (res.error || JSON.stringify(res.errors)));
		
					if(options.error) {
						options.error?.(err);
					} else {
						console.error(err);
					}
				} else {
					options.next?.(res.data);
				}
			},
			error(err) {
				if(options.error) {
					options.error?.(err);
				} else {
					console.error('Uncaught subscription error:', err);
				}
			},
			complete() {
				options?.complete?.();
			}
		});
	}

	/**
	 * Create a new subscription manager which allows the
	 * collection of subscription and simultaneous disposal
	 * of all active subscriptions.
	 * 
	 * @returns SubscriptionManager
	 */
	public createManager(): SubscriptionManager {
		const delegate = composite();

		return {
			subscribe: (options) => {
				delegate.add(this.subscribe(options));
			},
			dispose() {
				delegate();
			}
		};
	}

	/**
	 * Connect to the API and open a socket
	 * for listening to subscriptions.
	 */
	public connect() {
		this.close();

		const origin = this.axios.defaults.baseURL;

		if(!origin || !origin.startsWith('http')) {
			throw new Error('Unexpected subscription origin');
		}

		const basePath = origin.replace('http', 'ws');
		const endpoint = buildPath(basePath, 'subscriptions');

		this.client = createClient({
			url: endpoint,
			lazy: false
		});
	}

	/**
	 * Close the API connection
	 */
	public close() {
		this.client?.dispose();
		this.client = undefined;
	}

	/**
	 * Resolve all fragments found within the given query source
	 * 
	 * @param query The query source
	 * @returns The resolved query
	 */
	private resolveFragments(query: string): string {
		return query.replace(/^# > (\w+)$/gm, (_match: string, name: string) => {
			const element = `./${name}.gql`;

			if(!this.fragments.keys().includes(element)) {
				throw new Error('Unknown fragment ' + element);
			}
		
			return this.resolveFragments(this.fragments(element).default);
		});
	}

	/**
	 * Parse the given query or query file name
	 * 
	 * @param source The source
	 * @returns The document node
	 */
	private parseQuery(source: DocumentNode|string): DocumentNode {
		if(typeof source == 'string') {
			const query = this.resolveFragments(source);

			try {
				return parse(query);
			} catch(err) {
				console.error('Failed to parse query string', query);
				throw err;
			}
		} else {
			return source;
		}
	}

}

/**
 * An error thrown when a query fails and responds
 * with one or more errors.
 */
export class ServerApiError extends Error {

	public readonly errors: any[];

	public constructor(response: any) {
		if(process.env.NODE_ENV == 'development') {
			super('Api request failed\n\n' + response.errors.map((err: any) => `- ${err.message}\n`));
		} else {
			super('Api request failed');
		}
		
		this.errors = response.errors || [];
	}

	/**
	 * Returns whether the server error contains any
	 * of the specified error codes.
	 * 
	 * @param errCode The error code string
	 * @returns True if the error contains the code
	 */
	public hasErrorOfCode(errCode: string): boolean {
		return this.errors.some(err => err.code == errCode);
	}

}

/**
 * The config that may be passed to an API query
 */
export interface QueryConfig {
	endpoint?: string;
	cancel?: CancelTokenSource,
	operation?: string,
	options?: AxiosRequestConfig
}

/**
 * The subscription options
 */
export interface SubscriptionOptions extends Partial<Sink<any>> {
	query: DocumentNode|string,
	variables?: any,
	config?: QueryConfig
}

/**
 * The SubscriptionManager can collect multiple
 * active subscriptions and dispose of them at once.
 */
export interface SubscriptionManager {

	/**
	 * Executes the given query and listens to the responses.
	 * The subscription, together with all active subscriptions
	 * on this manager, are disposed when the subscription
	 * manager is disposed.
	 *
	 * @param options The subscription options
	 */
	subscribe(options: SubscriptionOptions): void;

	/**
	 * Dispose all active subscriptions on this
	 * subscription manager.
	 */
	dispose(): void;

}