import type IResponseCache from './IResponseCache.js';
import type ICachedResponse from './ICachedResponse.js';
import CachedResponseStateEnum from './CachedResponseStateEnum.js';
import type ICacheableRequest from './ICacheableRequest.js';
import type ICacheableResponse from './ICacheableResponse.js';
import Headers from '../../Headers.js';
import ResponseCacheFileSystem from './ResponseCacheFileSystem.js';

const UPDATE_RESPONSE_HEADERS = ['Cache-Control', 'Last-Modified', 'Vary', 'ETag'];

/**
 * Fetch response cache.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
 * @see https://www.mnot.net/cache_docs/
 */
export default class ResponseCache implements IResponseCache {
	public fileSystem: ResponseCacheFileSystem;
	#entries: Map<string, ICachedResponse[]> = new Map();

	/**
	 * Constructor.
	 */
	constructor() {
		this.fileSystem = new ResponseCacheFileSystem(this.#entries);
	}

	/**
	 * Returns cached response.
	 *
	 * @param request Request.
	 * @returns Cached response.
	 */
	public get(request: ICacheableRequest): ICachedResponse | null {
		if (request.headers.get('Cache-Control')?.includes('no-cache')) {
			return null;
		}

		const url = request.url;
		const entries = this.#entries.get(url);

		if (entries) {
			for (let i = 0, max = entries.length; i < max; i++) {
				const entry = entries[i];
				let isMatch = entry.request.method === request.method;
				if (isMatch) {
					for (const header of Object.keys(entry.vary)) {
						const requestHeader = request.headers.get(header);
						if (requestHeader !== null && entry.vary[header] !== requestHeader) {
							isMatch = false;
							break;
						}
					}
				}
				if (isMatch) {
					if (entry.expires && entry.expires < Date.now()) {
						if (entry.lastModified) {
							entry.state = CachedResponseStateEnum.stale;
						} else if (!entry.etag) {
							entries.splice(i, 1);
							return null;
						}
					}
					return entry;
				}
			}
		}
		return null;
	}

	/**
	 * Adds a cache entity.
	 *
	 * @param request Request.
	 * @param response Response.
	 * @returns Cached response.
	 */
	public add(request: ICacheableRequest, response: ICacheableResponse): ICachedResponse | null {
		// We should only cache GET and HEAD requests.
		if (
			(request.method !== 'GET' && request.method !== 'HEAD') ||
			request.headers.get('Cache-Control')?.includes('no-cache')
		) {
			return null;
		}

		const url = request.url;
		let cachedResponse = this.get(request);

		if (response.status === 304) {
			if (!cachedResponse) {
				throw new Error('ResponseCache: Cached response not found.');
			}

			for (const name of UPDATE_RESPONSE_HEADERS) {
				if (response.headers.has(name)) {
					cachedResponse.response.headers.set(name, response.headers.get(name)!);
				}
			}

			cachedResponse.cacheUpdateTime = Date.now();
			cachedResponse.state = CachedResponseStateEnum.fresh;
		} else {
			if (cachedResponse) {
				const entries = this.#entries.get(url);
				if (entries) {
					const index = entries.indexOf(cachedResponse);
					if (index !== -1) {
						entries.splice(index, 1);
					}
				}
			}

			cachedResponse = {
				response: {
					status: response.status,
					statusText: response.statusText,
					url: response.url,
					headers: new Headers(response.headers),
					// We need to wait for the body to be consumed and then populated if set to true (e.g. by using Response.text()).
					waitingForBody: response.waitingForBody,
					body: response.body ?? null
				},
				request: {
					headers: request.headers,
					method: request.method
				},
				vary: {},
				expires: null,
				etag: null,
				cacheUpdateTime: Date.now(),
				lastModified: null,
				mustRevalidate: false,
				staleWhileRevalidate: false,
				state: CachedResponseStateEnum.fresh,
				virtual: response.virtual ?? false
			};

			let entries = this.#entries.get(url);

			if (!entries) {
				entries = [];
				this.#entries.set(url, entries);
			}

			entries.push(cachedResponse);
		}

		if (response.headers.has('Cache-Control')) {
			const age = response.headers.get('Age');

			for (const part of response.headers.get('Cache-Control')!.split(',')) {
				const [key, value] = part.trim().split('=');
				switch (key) {
					case 'max-age':
						cachedResponse.expires =
							Date.now() + parseFloat(value) * 1000 - (age ? parseFloat(age) * 1000 : 0);
						break;
					case 'no-cache':
					case 'no-store':
						const entries = this.#entries.get(url);
						if (entries) {
							const index = entries.indexOf(cachedResponse);
							if (index !== -1) {
								entries.splice(index, 1);
							}
						}
						return null;
					case 'must-revalidate':
						cachedResponse.mustRevalidate = true;
						break;
					case 'stale-while-revalidate':
						cachedResponse.staleWhileRevalidate = true;
						break;
				}
			}
		}

		if (response.headers.has('Last-Modified')) {
			cachedResponse.lastModified = Date.parse(response.headers.get('Last-Modified')!);
		}

		if (response.headers.has('Vary')) {
			for (const header of response.headers.get('Vary')!.split(',')) {
				const name = header.trim();
				const value = request.headers.get(name);
				if (value) {
					cachedResponse.vary[name] = value;
				}
			}
		}

		if (response.headers.has('ETag')) {
			cachedResponse.etag = response.headers.get('ETag');
		}

		if (!cachedResponse.expires) {
			const expires = response.headers.get('Expires');
			if (expires) {
				cachedResponse.expires = Date.parse(expires);
			}
		}

		// Cache is invalid if it has expired and doesn't have an ETag.
		if (!cachedResponse.etag && (!cachedResponse.expires || cachedResponse.expires < Date.now())) {
			const entries = this.#entries.get(url);
			if (entries) {
				const index = entries.indexOf(cachedResponse);
				if (index !== -1) {
					entries.splice(index, 1);
				}
			}
			return null;
		}

		return cachedResponse;
	}

	/**
	 * Clears the cache.
	 *
	 * @param [options] Options.
	 * @param [options.url] URL.
	 * @param [options.toTime] Removes all entries that are older than this time. Time in MS.
	 */
	public clear(options?: { url?: string; toTime?: number }): void {
		if (options) {
			if (options.toTime) {
				for (const key of options.url ? [options.url] : this.#entries.keys()) {
					const entry = this.#entries.get(key);
					if (entry) {
						for (let i = 0, max = entry.length; i < max; i++) {
							if (entry[i].cacheUpdateTime < options.toTime) {
								entry.splice(i, 1);
								i--;
								max--;
							}
						}
					}
				}
			} else if (options.url) {
				this.#entries.delete(options.url);
			}
		} else {
			this.#entries.clear();
		}
	}
}
