/**
 * The client for this plugin's own REST namespace.
 *
 * It never talks to RazorBooking directly. Every call goes to
 * `razorbooking/v1` on this site, and the PHP behind it holds the credential
 * and does the talking. That is deliberate: the API token must not be readable
 * from a browser, and a WordPress administrator's session is the only thing
 * that should authorise these calls.
 *
 * Errors come back as a thrown `ApiError` carrying the code PHP sent, because
 * the screens branch on the code — a revoked credential is a different screen
 * from a timeout, and both are different from a validation failure.
 */

import { boot } from './boot';
import { forgetScreens } from './screenCache';

export class ApiError extends Error {
	constructor(
		message: string,
		readonly code: string,
		readonly status: number
	) {
		super( message );
		this.name = 'ApiError';
	}
}

type Query = Record< string, string | number | boolean | undefined >;

function url( path: string, query?: Query ): string {
	const base = boot().root.replace( /\/+$/, '' );
	const target = new URL( `${ base }/${ path.replace( /^\/+/, '' ) }` );

	for ( const [ key, value ] of Object.entries( query ?? {} ) ) {
		if ( value !== undefined ) {
			target.searchParams.set( key, String( value ) );
		}
	}

	return target.toString();
}

/*
 * WordPress REST nonces expire after 12–24 hours, so a tab left open overnight
 * fails every save with `rest_cookie_invalid_nonce`. On that error, fetch a
 * fresh nonce from core's `rest-nonce` admin-ajax action and retry once.
 */
let currentNonce: string | null = null;

const nonce = () => currentNonce ?? boot().nonce;

declare global {
	interface Window {
		/** Defined by WordPress on every admin screen. */
		ajaxurl: string;
	}
}

async function refreshNonce(): Promise< boolean > {
	try {
		const response = await fetch( `${ window.ajaxurl }?action=rest-nonce`, { credentials: 'same-origin' } );
		const fresh = ( await response.text() ).trim();

		// Logged out answers "0" or an HTML page.
		if ( response.ok && /^[a-f0-9]{10}$/i.test( fresh ) ) {
			currentNonce = fresh;

			return true;
		}
	} catch {
		// Offline: the original error is the one to show.
	}

	return false;
}

async function call< T >( method: string, path: string, body?: unknown, query?: Query, retried = false ): Promise< T > {
	const response = await fetch( url( path, query ), {
		method,
		credentials: 'same-origin',
		headers: {
			'Content-Type': 'application/json',
			// WordPress authenticates REST calls from the admin by cookie plus
			// this nonce. Without it every request is treated as logged out.
			'X-WP-Nonce': nonce(),
		},
		body: body === undefined ? undefined : JSON.stringify( body ),
	} );

	// 204 and an empty 200 are both legitimate answers to a write.
	const text = await response.text();
	const payload = text ? safeParse( text ) : null;

	if ( ! response.ok ) {
		const code =
			isRecord( payload ) && typeof payload.code === 'string' ? payload.code : 'http_error';
		const message =
			isRecord( payload ) && typeof payload.message === 'string'
				? payload.message
				: `Request failed (${ response.status })`;

		if ( code === 'rest_cookie_invalid_nonce' && ! retried && ( await refreshNonce() ) ) {
			return call< T >( method, path, body, query, true );
		}

		throw new ApiError( message, code, response.status );
	}

	if ( method !== 'GET' ) {
		// What screens remember may be out of date now (screenCache.ts).
		forgetScreens();
	}

	if ( method !== 'GET' && changesRazorbookingData( path ) ) {
		// Bring the store up to date before the save resolves, so the screen
		// that reloads after it shows the change. One request brings every
		// part (Razorbooking_Sync). Quiet on failure: the save itself worked,
		// and Sync is there.
		await call( 'POST', 'sync' ).catch( () => undefined );
	}

	return payload as T;
}

/*
 * Saves that change RazorBooking data the store holds. Every screen reads the
 * store, and the parts cross screens (services list the team and categories,
 * the team lists services), so the whole store is fetched again.
 */
const RAZORBOOKING_DATA = [ 'services', 'categories', 'team', 'resources' ];

function changesRazorbookingData( path: string ): boolean {
	return RAZORBOOKING_DATA.includes( path.replace( /^\/+/, '' ).split( '/' )[ 0 ] );
}

function safeParse( text: string ): unknown {
	try {
		return JSON.parse( text );
	} catch {
		// A PHP notice printed ahead of the JSON is the usual cause, and the
		// raw text is far more useful than "Unexpected token <".
		throw new ApiError( text.slice( 0, 300 ), 'invalid_json', 0 );
	}
}

/** A caught error's message, or `fallback` when it has none. */
export function messageOf( caught: unknown, fallback: string ): string {
	return caught instanceof Error && caught.message ? caught.message : fallback;
}

function isRecord( value: unknown ): value is Record< string, unknown > {
	return typeof value === 'object' && value !== null;
}

export const api = {
	get: < T >( path: string, query?: Query ) => call< T >( 'GET', path, undefined, query ),
	post: < T >( path: string, body?: unknown ) => call< T >( 'POST', path, body ),
	put: < T >( path: string, body?: unknown ) => call< T >( 'PUT', path, body ),
	del: < T >( path: string ) => call< T >( 'DELETE', path ),
};
