import { WpInlineScript } from '../wpInlineScript';
import {
	WeBRoutes,
	WeBRoute,
	WEB_FETCH_CONSENT_REQUIRED,
	WEB_FETCH_NOT_CONNECTED,
	WEB_FETCH_NOT_REGISTERED,
	WEB_FETCH_SESSION_EXPIRED,
	WEB_FETCH_UNREACHABLE,
} from './Fetch';
import {
	WeBRecordInfoResponse,
	WeBWireError,
	WeBWireSave,
	WeBWorkspaceListResponse,
	WeBWorkspaceResponse,
	WeBWorkspaceSavedResponse,
	WeBWorkspaceSaveListResponse,
	WeBWorkspaceSaveResponse,
	WeBWorkspaceSaveStatusResponse,
} from './types';

/**
 * The `code` values which become a constant rather than the server's message.
 *
 * The chrome branches on these three — sign in, register, give consent — so
 * the rejection has to be the constant; the server's own wording rides along
 * as `cause`. Every other code (`invalid`, `not-found`, `unavailable`, `''`)
 * is shown as the message it came with.
 */
const WEB_FETCH_CODE_REJECTIONS: { [ code: string ]: string } = {
	consent_required: WEB_FETCH_CONSENT_REQUIRED,
	'not-connected': WEB_FETCH_NOT_CONNECTED,
	'not-registered': WEB_FETCH_NOT_REGISTERED,
};

/**
 * The inline script `ButtercupWP::admin_enqueue_scripts()` prints before the
 * bundle — `{url, token}`. It exists on the graph admin page and nowhere else,
 * which is why this class is only ever constructed from the custom element.
 */
declare const we360cloudGraph: WpInlineScript;

type WeBRecordInfoWaiter = {
	reject: ( reason: Error ) => void;
	resolve: ( response: WeBRecordInfoResponse ) => void;
};

/**
 * `WeBRoutes` over WordPress's `admin-ajax.php`.
 *
 * Every route is one form-encoded POST to the same url carrying the same
 * `action` and nonce, distinguished by `we360cloud_graph_route` — WordPress's
 * ajax endpoint, not a REST namespace.
 *
 * `getRecordInfo` is the only method with any machinery: the info bar asks for
 * a description per selected record, and selection changes several times a
 * second while the user drags a marquee. So ids are queued, the request is
 * debounced, and everything the server has already answered is served from a
 * client-side cache.
 */
export class WeBRoutesWP implements WeBRoutes {
	/** How long the record-info queue waits for the selection to settle */
	public static readonly RECORD_INFO_DEBOUNCE_MS = 1000;

	/** `WeBWPSiteResponder::MAX_RECORD_INFO` — the server answers no more per call */
	public static readonly RECORD_INFO_CHUNK = 100;

	private __recordInfoCache: WeBRecordInfoResponse = { items: {} };
	private __recordInfoDebounceId: number = 0;
	private __recordInfoQueuedIds: Set< string > = new Set();

	/**
	 * Ids the server has been asked about and had nothing for. Without this they
	 * would sit in the queue and be re-requested on every flush for the life of
	 * the page — an unresolvable id is *omitted* from the response, so its
	 * absence from the cache is not evidence it is unknown.
	 */
	private __recordInfoUnresolved: Set< string > = new Set();

	/**
	 * Everyone waiting on the next flush.
	 *
	 * The debounce resets the timer, so a caller cannot own the request it
	 * triggered — the previous generation resolved only the last caller's
	 * promise and quietly abandoned every earlier one, which shows in the info
	 * bar as placeholders that never resolve.
	 */
	private __recordInfoWaiters: WeBRecordInfoWaiter[] = [];

	/**
	 * One POST to `admin-ajax.php`.
	 *
	 * Three failure classes, distinct rejections: an unreadable or unreachable
	 * response, the nonce failure (which arrives as a **200** carrying
	 * `{success: false}`, so the status cannot be the test), and the standard
	 * `{error, message, code}` body every other failure uses — three of whose
	 * codes are the connection states the chrome branches on.
	 *
	 * @param route  - the value of `we360cloud_graph_route`
	 * @param params - the route's own parameters
	 */
	protected _fetch< ResponseType >(
		route: WeBRoute,
		params: { [ key: string ]: string } = {}
	): Promise< ResponseType > {
		return window
			.fetch( we360cloudGraph.url, {
				method: 'POST',
				headers: {
					'content-type': 'application/x-www-form-urlencoded',
				},
				body: new URLSearchParams( {
					action: 'we360cloud_graph',
					token: we360cloudGraph.token,
					we360cloud_graph_route: route,
					...params,
				} ).toString(),
			} )
			.then(
				( response ) =>
					response.json().then(
						( body ) => this.__result< ResponseType >( body ),
						() => {
							throw new Error( WEB_FETCH_UNREACHABLE );
						}
					),
				() => {
					throw new Error( WEB_FETCH_UNREACHABLE );
				}
			);
	}

	/**
	 * A parsed body, or the error it describes.
	 *
	 * @param body - whatever `response.json()` produced
	 */
	private __result< ResponseType >( body: unknown ): ResponseType {
		if ( ! body || typeof body !== 'object' ) {
			throw new Error( WEB_FETCH_UNREACHABLE );
		}

		//the nonce failure — HTTP 200, wp_send_json_error()'s shape
		if (
			'success' in body &&
			( body as { success: unknown } ).success === false
		) {
			throw new Error( WEB_FETCH_SESSION_EXPIRED );
		}

		//every other failure — WeBWPResponseError, at its own status
		if ( 'error' in body ) {
			const { code, message } = body as Partial< WeBWireError >;
			const shown =
				typeof message === 'string' && message !== ''
					? message
					: 'Error, unable to fetch data.';

			//a connection state: the constant is the rejection, the wording rides along
			if (
				typeof code === 'string' &&
				code in WEB_FETCH_CODE_REJECTIONS
			) {
				throw new Error( WEB_FETCH_CODE_REJECTIONS[ code ], {
					cause: shown,
				} );
			}

			throw new Error( shown );
		}

		return body as ResponseType;
	}

	/**
	 * Route `library/get` — the live site library as a draft, `id: ''`.
	 *
	 * Not gated on the connection: an unregistered site still edits.
	 */
	public getLibrary(): Promise< WeBWorkspaceResponse > {
		return this._fetch< WeBWorkspaceResponse >( 'library/get' );
	}

	/**
	 * Route `workspace/get` — a saved workspace's overlays over the library as
	 * it is now: the latest save's, or a named one's from the history.
	 *
	 * `saveId` is omitted rather than sent empty, so the server's "absent means
	 * latest" test is not asked to treat `''` as absent.
	 *
	 * @param graphId - the workspace id
	 * @param saveId  - a save from `listSaves`; absent for the latest
	 */
	public getWorkspace(
		graphId: string,
		saveId?: string
	): Promise< WeBWorkspaceSavedResponse > {
		return this._fetch< WeBWorkspaceSavedResponse >( 'workspace/get', {
			graphId,
			...( saveId === undefined ? {} : { saveId } ),
		} );
	}

	/**
	 * Route `workspace/saves` — the workspace's complete saves, newest first.
	 *
	 * @param graphId - the workspace id
	 */
	public listSaves(
		graphId: string
	): Promise< WeBWorkspaceSaveListResponse > {
		return this._fetch< WeBWorkspaceSaveListResponse >( 'workspace/saves', {
			graphId,
		} );
	}

	/**
	 * Route `workspace/save` — one immutable snapshot, assembled server-side
	 * from the live library and this save.
	 *
	 * The save goes as one JSON string in the form body — the transport is
	 * form-encoded throughout, and the server `json_decode`s that one field,
	 * which it still names `state`.
	 * `graphId` is omitted rather than sent empty for a draft, so the server's
	 * "absent means mint one" test is not asked to treat `''` as absent.
	 *
	 * @param graphId - the workspace to save into, or null for a draft's first save
	 * @param save    - what the browser knows and the server cannot rebuild
	 */
	public saveWorkspace(
		graphId: string | null,
		save: WeBWireSave
	): Promise< WeBWorkspaceSaveResponse > {
		return this._fetch< WeBWorkspaceSaveResponse >( 'workspace/save', {
			...( graphId === null ? {} : { graphId } ),
			state: JSON.stringify( save ),
		} );
	}

	/**
	 * Route `workspace/save/status` — where a save the server answered
	 * `processing` for has got to. The server asks the cloud once per call; the
	 * repetition is the caller's (`useWorkspaceSave`).
	 *
	 * @param saveId - the `saveId` a `workspace/save` answered with
	 */
	public getSaveStatus(
		saveId: string
	): Promise< WeBWorkspaceSaveStatusResponse > {
		return this._fetch< WeBWorkspaceSaveStatusResponse >(
			'workspace/save/status',
			{ saveId }
		);
	}

	/**
	 * Route `workspace/list` — the workspaces this user can open.
	 */
	public listWorkspaces(): Promise< WeBWorkspaceListResponse > {
		return this._fetch< WeBWorkspaceListResponse >( 'workspace/list' );
	}

	/**
	 * Descriptions and thumbnails for records, from cache where possible.
	 *
	 * The response is the **whole cache**, not just the ids asked for: the info
	 * bar looks its records up by id, and handing back everything known means a
	 * re-selection of a record fetched a moment ago renders immediately.
	 *
	 * @param recordIds - the records to describe
	 */
	public getRecordInfo(
		recordIds: readonly string[]
	): Promise< WeBRecordInfoResponse > {
		for ( const recordId of recordIds ) {
			if ( this.__recordInfoCache.items[ recordId ] ) {
				continue;
			}
			if ( this.__recordInfoUnresolved.has( recordId ) ) {
				continue;
			}

			this.__recordInfoQueuedIds.add( recordId );
		}

		//nothing to ask about — every id is either known or known to be unknowable
		if ( ! this.__recordInfoQueuedIds.size ) {
			return Promise.resolve( this.__recordInfoCache );
		}

		return new Promise( ( resolve, reject ) => {
			this.__recordInfoWaiters.push( { reject, resolve } );

			//debounced so a marquee's worth of selection changes is one request
			window.clearTimeout( this.__recordInfoDebounceId );

			this.__recordInfoDebounceId = window.setTimeout(
				this.__recordInfoFlush,
				WeBRoutesWP.RECORD_INFO_DEBOUNCE_MS
			);
		} );
	}

	/**
	 * Request everything queued and settle everyone waiting on it.
	 *
	 * A bound property because it is handed to `setTimeout`.
	 */
	private __recordInfoFlush = (): void => {
		const recordIds = [ ...this.__recordInfoQueuedIds ];
		const waiters = this.__recordInfoWaiters;

		this.__recordInfoWaiters = [];

		if ( ! recordIds.length ) {
			for ( const waiter of waiters ) {
				waiter.resolve( this.__recordInfoCache );
			}

			return;
		}

		//the server caps a batch at MAX_RECORD_INFO ids and silently drops the rest
		const chunks: string[][] = [];

		for (
			let i = 0;
			i < recordIds.length;
			i += WeBRoutesWP.RECORD_INFO_CHUNK
		) {
			chunks.push(
				recordIds.slice( i, i + WeBRoutesWP.RECORD_INFO_CHUNK )
			);
		}

		Promise.all(
			chunks.map( ( chunk ) =>
				this._fetch< WeBRecordInfoResponse >(
					'workspace/library/record/get',
					{ ids: chunk.join( ',' ) }
				)
			)
		).then(
			( responses ) => {
				for ( const response of responses ) {
					for ( const recordId in response.items ) {
						this.__recordInfoCache.items[ recordId ] =
							response.items[ recordId ];
					}
				}

				for ( const recordId of recordIds ) {
					this.__recordInfoQueuedIds.delete( recordId );

					//asked for and not answered: the record has no info to show
					if ( ! this.__recordInfoCache.items[ recordId ] ) {
						this.__recordInfoUnresolved.add( recordId );
					}
				}

				for ( const waiter of waiters ) {
					waiter.resolve( this.__recordInfoCache );
				}
			},
			( reason: Error ) => {
				//the ids stay queued, so the next call retries them
				for ( const waiter of waiters ) {
					waiter.reject( reason );
				}
			}
		);
	};
}
