import * as mime from "mime";
import {
	CacheControl,
	InternalError,
	MethodNotAllowedError,
	NotFoundError,
	Options,
} from "./types";
import type { AssetManifestType } from "./types";

declare global {
	const __STATIC_CONTENT: KVNamespace | undefined,
		__STATIC_CONTENT_MANIFEST: string;
}

const defaultCacheControl: CacheControl = {
	browserTTL: null,
	edgeTTL: 2 * 60 * 60 * 24, // 2 days
	bypassCache: false, // do not bypass Cloudflare's cache
};

const parseStringAsObject = <T>(maybeString: string | T): T =>
	typeof maybeString === "string"
		? (JSON.parse(maybeString) as T)
		: maybeString;

const getAssetFromKVDefaultOptions: Partial<Options> = {
	ASSET_NAMESPACE:
		typeof __STATIC_CONTENT !== "undefined" ? __STATIC_CONTENT : undefined,
	ASSET_MANIFEST:
		typeof __STATIC_CONTENT_MANIFEST !== "undefined"
			? parseStringAsObject<AssetManifestType>(__STATIC_CONTENT_MANIFEST)
			: {},
	cacheControl: defaultCacheControl,
	defaultMimeType: "text/plain",
	defaultDocument: "index.html",
	pathIsEncoded: false,
	defaultETag: "strong",
};

function assignOptions(options?: Partial<Options>): Options {
	// Assign any missing options passed in to the default
	// options.mapRequestToAsset is handled manually later
	return <Options>Object.assign({}, getAssetFromKVDefaultOptions, options);
}

/**
 * maps the path of incoming request to the request pathKey to look up
 * in bucket and in cache
 * e.g.  for a path '/' returns '/index.html' which serves
 * the content of bucket/index.html
 * @param {Request} request incoming request
 */
const mapRequestToAsset = (request: Request, options?: Partial<Options>) => {
	options = assignOptions(options);

	const parsedUrl = new URL(request.url);
	let pathname = parsedUrl.pathname;

	if (pathname.endsWith("/")) {
		// If path looks like a directory append options.defaultDocument
		// e.g. If path is /about/ -> /about/index.html
		pathname = pathname.concat(options.defaultDocument);
	} else if (!mime.getType(pathname)) {
		// If path doesn't look like valid content
		//  e.g. /about.me ->  /about.me/index.html
		pathname = pathname.concat("/" + options.defaultDocument);
	}

	parsedUrl.pathname = pathname;
	return new Request(parsedUrl.toString(), request);
};

/**
 * maps the path of incoming request to /index.html if it evaluates to
 * any HTML file.
 * @param {Request} request incoming request
 */
function serveSinglePageApp(
	request: Request,
	options?: Partial<Options>
): Request {
	options = assignOptions(options);

	// First apply the default handler, which already has logic to detect
	// paths that should map to HTML files.
	request = mapRequestToAsset(request, options);

	const parsedUrl = new URL(request.url);

	// Detect if the default handler decided to map to
	// a HTML file in some specific directory.
	if (parsedUrl.pathname.endsWith(".html")) {
		// If expected HTML file was missing, just return the root index.html (or options.defaultDocument)
		return new Request(
			`${parsedUrl.origin}/${options.defaultDocument}`,
			request
		);
	} else {
		// The default handler decided this is not an HTML page. It's probably
		// an image, CSS, or JS file. Leave it as-is.
		return request;
	}
}

/**
 * takes the path of the incoming request, gathers the appropriate content from KV, and returns
 * the response
 *
 * @param {FetchEvent} event the fetch event of the triggered request
 * @param {{mapRequestToAsset: (string: Request) => Request, cacheControl: {bypassCache:boolean, edgeTTL: number, browserTTL:number}, ASSET_NAMESPACE: any, ASSET_MANIFEST:any}} [options] configurable options
 * @param {CacheControl} [options.cacheControl] determine how to cache on Cloudflare and the browser
 * @param {typeof(options.mapRequestToAsset)} [options.mapRequestToAsset]  maps the path of incoming request to the request pathKey to look up
 * @param {Object | string} [options.ASSET_NAMESPACE] the binding to the namespace that script references
 * @param {any} [options.ASSET_MANIFEST] the map of the key to cache and store in KV
 * */

type Evt = {
	request: Request;
	waitUntil: (promise: Promise<unknown>) => void;
};

const getAssetFromKV = async (
	event: Evt,
	options?: Partial<Options>
): Promise<Response> => {
	options = assignOptions(options);

	const request = event.request;
	const ASSET_NAMESPACE = options.ASSET_NAMESPACE;
	const ASSET_MANIFEST = parseStringAsObject<AssetManifestType>(
		options.ASSET_MANIFEST
	);

	if (typeof ASSET_NAMESPACE === "undefined") {
		throw new InternalError(`there is no KV namespace bound to the script`);
	}

	const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, ""); // strip any preceding /'s
	let pathIsEncoded = options.pathIsEncoded;
	let requestKey;
	// if options.mapRequestToAsset is explicitly passed in, always use it and assume user has own intentions
	// otherwise handle request as normal, with default mapRequestToAsset below
	if (options.mapRequestToAsset) {
		requestKey = options.mapRequestToAsset(request);
	} else if (ASSET_MANIFEST[rawPathKey]) {
		requestKey = request;
	} else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) {
		pathIsEncoded = true;
		requestKey = request;
	} else {
		const mappedRequest = mapRequestToAsset(request);
		const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(
			/^\/+/,
			""
		);
		if (ASSET_MANIFEST[decodeURIComponent(mappedRawPathKey)]) {
			pathIsEncoded = true;
			requestKey = mappedRequest;
		} else {
			// use default mapRequestToAsset
			requestKey = mapRequestToAsset(request, options);
		}
	}

	const SUPPORTED_METHODS = ["GET", "HEAD"];
	if (!SUPPORTED_METHODS.includes(requestKey.method)) {
		throw new MethodNotAllowedError(
			`${requestKey.method} is not a valid request method`
		);
	}

	const parsedUrl = new URL(requestKey.url);
	const pathname = pathIsEncoded
		? decodeURIComponent(parsedUrl.pathname)
		: parsedUrl.pathname; // decode percentage encoded path only when necessary

	// pathKey is the file path to look up in the manifest
	let pathKey = pathname.replace(/^\/+/, ""); // remove prepended /

	// @ts-expect-error we should pick cf types here
	const cache = caches.default;
	let mimeType = mime.getType(pathKey) || options.defaultMimeType;
	if (mimeType.startsWith("text") || mimeType === "application/javascript") {
		mimeType += "; charset=utf-8";
	}

	let shouldEdgeCache = false; // false if storing in KV by raw file path i.e. no hash
	// check manifest for map from file path to hash
	if (typeof ASSET_MANIFEST !== "undefined") {
		if (ASSET_MANIFEST[pathKey]) {
			pathKey = ASSET_MANIFEST[pathKey];
			// if path key is in asset manifest, we can assume it contains a content hash and can be cached
			shouldEdgeCache = true;
		}
	}

	// TODO this excludes search params from cache, investigate ideal behavior
	const cacheKey = new Request(`${parsedUrl.origin}/${pathKey}`, request);

	// if argument passed in for cacheControl is a function then
	// evaluate that function. otherwise return the Object passed in
	// or default Object
	const evalCacheOpts = (() => {
		switch (typeof options.cacheControl) {
			case "function":
				return options.cacheControl(request);
			case "object":
				return options.cacheControl;
			default:
				return defaultCacheControl;
		}
	})();

	// formats the etag depending on the response context. if the entityId
	// is invalid, returns an empty string (instead of null) to prevent the
	// the potentially disastrous scenario where the value of the Etag resp
	// header is "null". Could be modified in future to base64 encode etc
	const formatETag = (
		entityId: string = pathKey,
		validatorType: string = options.defaultETag
	) => {
		if (!entityId) {
			return "";
		}
		switch (validatorType) {
			case "weak":
				if (!entityId.startsWith("W/")) {
					if (entityId.startsWith(`"`) && entityId.endsWith(`"`)) {
						return `W/${entityId}`;
					}
					return `W/"${entityId}"`;
				}
				return entityId;
			case "strong":
				if (entityId.startsWith(`W/"`)) {
					entityId = entityId.replace("W/", "");
				}
				if (!entityId.endsWith(`"`)) {
					entityId = `"${entityId}"`;
				}
				return entityId;
			default:
				return "";
		}
	};

	options.cacheControl = Object.assign({}, defaultCacheControl, evalCacheOpts);

	// override shouldEdgeCache if options say to bypassCache
	if (
		options.cacheControl.bypassCache ||
		options.cacheControl.edgeTTL === null ||
		request.method == "HEAD"
	) {
		shouldEdgeCache = false;
	}
	// only set max-age if explicitly passed in a number as an arg
	const shouldSetBrowserCache =
		typeof options.cacheControl.browserTTL === "number";

	let response = null;
	if (shouldEdgeCache) {
		response = await cache.match(cacheKey);
	}

	if (response) {
		if (response.status > 300 && response.status < 400) {
			if (response.body && "cancel" in Object.getPrototypeOf(response.body)) {
				// Body exists and environment supports readable streams
				response.body.cancel();
			} else {
				// Environment doesnt support readable streams, or null repsonse body. Nothing to do
			}
			response = new Response(null, response);
		} else {
			// fixes #165
			const opts = {
				headers: new Headers(response.headers),
				status: 0,
				statusText: "",
			};

			opts.headers.set("cf-cache-status", "HIT");

			if (response.status) {
				opts.status = response.status;
				opts.statusText = response.statusText;
			} else if (opts.headers.has("Content-Range")) {
				opts.status = 206;
				opts.statusText = "Partial Content";
			} else {
				opts.status = 200;
				opts.statusText = "OK";
			}
			response = new Response(response.body, opts);
		}
	} else {
		const body = await ASSET_NAMESPACE.get(pathKey, "arrayBuffer");
		if (body === null) {
			throw new NotFoundError(
				`could not find ${pathKey} in your content namespace`
			);
		}
		response = new Response(body);

		if (shouldEdgeCache) {
			response.headers.set("Accept-Ranges", "bytes");
			response.headers.set("Content-Length", String(body.byteLength));
			// set etag before cache insertion
			if (!response.headers.has("etag")) {
				response.headers.set("etag", formatETag(pathKey));
			}
			// determine Cloudflare cache behavior
			response.headers.set(
				"Cache-Control",
				`max-age=${options.cacheControl.edgeTTL}`
			);
			event.waitUntil(cache.put(cacheKey, response.clone()));
			response.headers.set("CF-Cache-Status", "MISS");
		}
	}
	response.headers.set("Content-Type", mimeType);

	if (response.status === 304) {
		const etag = formatETag(response.headers.get("etag"));
		const ifNoneMatch = cacheKey.headers.get("if-none-match");
		const proxyCacheStatus = response.headers.get("CF-Cache-Status");
		if (etag) {
			if (ifNoneMatch && ifNoneMatch === etag && proxyCacheStatus === "MISS") {
				response.headers.set("CF-Cache-Status", "EXPIRED");
			} else {
				response.headers.set("CF-Cache-Status", "REVALIDATED");
			}
			response.headers.set("etag", formatETag(etag, "weak"));
		}
	}
	if (shouldSetBrowserCache) {
		response.headers.set(
			"Cache-Control",
			`max-age=${options.cacheControl.browserTTL}`
		);
	} else {
		response.headers.delete("Cache-Control");
	}
	return response;
};

export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp };
export {
	Options,
	CacheControl,
	MethodNotAllowedError,
	NotFoundError,
	InternalError,
};
