import {
    WebExchange,
    HttpRequest,
    ReadonlyHttpHeaders,
    ServerHttpResponse, ServerHttpRequest
} from './types.ts';
import getLogger from '../logger.js';
import {IOGateway} from '@interopio/gateway';
import {HttpServerResponse} from './exchange.ts';

function isSameOrigin(request: HttpRequest<ReadonlyHttpHeaders>) {
    const origin = request.headers.one('origin');
    if (origin === undefined) {
        return true;
    }
    const url = request.URL;
    const actualProtocol = url.protocol;
    const actualHost = url.host;

    const originUrl = new URL(origin);

    const originHost = originUrl.host;
    const originProtocol = originUrl.protocol;
    return actualProtocol === originProtocol
        && actualHost === originHost;
}

/**
 * Returns `true` if the request is a valid CORS one by checking `Origin` header presence and ensuring origins differ.
 */
export function isCorsRequest(request: HttpRequest<ReadonlyHttpHeaders>): boolean {
    return request.headers.has('origin') && !isSameOrigin(request);

}

export function isPreFlightRequest(request: HttpRequest): boolean {
    return request.method === 'OPTIONS'
        && request.headers.has('origin')
        && request.headers.has('access-control-request-method');
}

const VARY_HEADERS: readonly string[] = ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers'];

/**
 * Processes a request given a {@link CorsConfig}.
 *
 * @param exchange the current exchange
 * @param config the CORS configuration to use, possibly `undefined` in which case pre-flight requests are rejected, but all others allowed
 * @returns `false` if the request is rejected, `true` otherwise
 */
export function processRequest(exchange: WebExchange, config?: CorsConfig): boolean {
    const {request, response} = exchange;
    const responseHeaders = response.headers;

    if (!responseHeaders.has('Vary')) {
        responseHeaders.set('Vary', VARY_HEADERS.join(', '));
    }
    else {
        const varyHeaders = responseHeaders.list('Vary');
        for (const header of VARY_HEADERS) {
            if (!varyHeaders.find(h => h === header)) {
                varyHeaders.push(header);
            }
        }
        responseHeaders.set('Vary', varyHeaders.join(', '));
    }

    try {
        if (!isCorsRequest(request)) {
            return true;
        }
    } catch (e) {
        if(logger.enabledFor('debug')) {
            logger.debug(`reject: origin is malformed`);
        }
        rejectRequest(response);
        return false;
    }

    if (responseHeaders.has('access-control-allow-origin')) {
        logger.trace(`skip: already contains "Access-Control-Allow-Origin"`);
        return true;
    }

    const preFlightRequest = isPreFlightRequest(request);

    if (config) {
        return handleInternal(exchange, config, preFlightRequest);
    }
    if (preFlightRequest) {
        rejectRequest(response);
        return false;
    }
    return true;
}

export type CorsConfig = {
    origins?: {
        allow?: '*' | IOGateway.Filtering.Matcher[]
    }
    methods?: {
        allow?: '*' | string[]
    }
    headers?: {
        allow?: '*' | string[]
        expose?: string[]
    }
    credentials?: {
        allow?: boolean
    },
    privateNetwork?: {
        allow?: boolean
    }
}

export /*testing*/ function validateConfig(config?: CorsConfig): CorsConfig | undefined {
    if (config) {

        const headers = config.headers;
        if (headers?.allow && headers.allow !== ALL) {
            headers.allow = headers.allow.map(header => header.toLowerCase());
        }
        const origins = config.origins;
        if (origins?.allow && origins.allow !== ALL) {
            origins.allow = origins.allow.map(origin => {
                if (typeof origin === 'string') {
                    // exact match
                    return origin.toLowerCase();
                }
                return origin;
            });
        }
        return config;
    }
}

const handler = (config?: CorsConfig) => {
    validateConfig(config);
    return async (ctx: WebExchange<ServerHttpRequest, HttpServerResponse>, next: () => Promise<void>) => {
        const isValid = processRequest(ctx, config);
        if (!isValid || isPreFlightRequest(ctx.request)) {
            // do we need to call end?
            ctx.response._res.end();
        } else {
            await next();
        }
    };
};

export default (config?: CorsConfig) => [handler(config)];


const logger = getLogger('cors');

function rejectRequest(response: ServerHttpResponse) {
    response.statusCode = 403;
}

function handleInternal(exchange: WebExchange,
                        config: CorsConfig, preFlightRequest: boolean): boolean {
    const {request, response} = exchange;
    const responseHeaders = response.headers;

    const requestOrigin = request.headers.one('origin');
    const allowOrigin = checkOrigin(config, requestOrigin);

    if (allowOrigin === undefined) {
        if (logger.enabledFor('debug')) {
            logger.debug(`reject: '${requestOrigin}' origin is not allowed`);
        }
        rejectRequest(response);
        return false;
    }

    const requestMethod = getMethodToUse(request, preFlightRequest);
    const allowMethods = checkMethods(config, requestMethod);
    if (allowMethods === undefined) {
        if (logger.enabledFor('debug')) {
            logger.debug(`reject: HTTP '${requestMethod}' is not allowed`);
        }
        rejectRequest(response);
        return false;
    }

    const requestHeaders = getHeadersToUse(request, preFlightRequest);
    const allowHeaders = checkHeaders(config, requestHeaders);
    if (preFlightRequest && allowHeaders === undefined) {
        if (logger.enabledFor('debug')) {
            logger.debug(`reject: headers '${requestHeaders}' are not allowed`);
        }
        rejectRequest(response);
        return false;
    }

    responseHeaders.set('access-control-allow-origin', allowOrigin);

    if (preFlightRequest) {
        responseHeaders.set('access-control-allow-methods', allowMethods.join(','));

    }
    if (preFlightRequest && allowHeaders !== undefined && allowHeaders.length  > 0) {
        responseHeaders.set('access-control-allow-headers', allowHeaders.join(', '));
    }
    const exposeHeaders = config.headers?.expose;
    if (exposeHeaders && exposeHeaders.length > 0) {
        responseHeaders.set('access-control-expose-headers', exposeHeaders.join(', '));
    }
    if (config.credentials?.allow) {
        responseHeaders.set('access-control-allow-credentials', 'true');
    }
    if (config.privateNetwork?.allow && request.headers.one('access-control-request-private-network') === 'true') {
        responseHeaders.set('access-control-allow-private-network', 'true');
    }

    // no max-age support :)
    return true;
}

const ALL = '*';
const DEFAULT_METHODS = ['GET', 'HEAD'];

function validateAllowCredentials(config: CorsConfig) {
    if (config.credentials?.allow === true && config.origins?.allow === ALL) {
        throw new Error(`when credentials.allow is true origins.allow cannot be "*"`);
    }
}

function validateAllowPrivateNetwork(config: CorsConfig) {
    if (config.privateNetwork?.allow === true && config.origins?.allow === ALL) {
        throw new Error(`when privateNetwork.allow is true origins.allow cannot be "*"`);
    }
}

function checkOrigin(config: CorsConfig, origin? :string): string | undefined {
    if (origin) {
        const allowedOrigins = config.origins?.allow;
        if (allowedOrigins) {
            if (allowedOrigins === ALL) {
                validateAllowCredentials(config);
                validateAllowPrivateNetwork(config);
                return ALL;
            }
            const originToCheck = trimTrailingSlash(origin.toLowerCase());

            for (const allowedOrigin of allowedOrigins) {
                if ((allowedOrigin ===  ALL) || IOGateway.Filtering.valueMatches(allowedOrigin, originToCheck)) {
                    return origin;
                }
            }
        }
    }
}

function checkMethods(config: CorsConfig, requestMethod?: string): string[] | undefined {
    if (requestMethod) {
        const allowedMethods = config.methods?.allow ?? DEFAULT_METHODS;
        if (allowedMethods === ALL) {
            return [requestMethod];
        }
        if (IOGateway.Filtering.valuesMatch(allowedMethods, requestMethod)) {
            return allowedMethods;
        }
    }
}

function checkHeaders(config: CorsConfig, requestHeaders?: string[]): string[] | undefined {
    if (requestHeaders === undefined) {
        return;
    }
    if (requestHeaders.length == 0) {
        return [];
    }
    const allowedHeaders = config.headers?.allow;
    if (allowedHeaders === undefined) {
        return;
    }
    const allowAnyHeader = allowedHeaders === ALL;
    const result: string[] = [];
    for (const requestHeader of requestHeaders) {
        const value = requestHeader?.trim();
        if (value) {
            if (allowAnyHeader) {
                result.push(value);
            }
            else {
                for (const allowedHeader of allowedHeaders) {
                    if (value.toLowerCase() == allowedHeader) {
                        result.push(value);
                        break;
                    }
                }
            }
        }
    }
    if (result.length > 0) {
        return result;
    }
}

function trimTrailingSlash(origin: string): string {
    return origin.endsWith('/') ? origin.slice(0, -1) : origin;
}

function getMethodToUse(request: HttpRequest<ReadonlyHttpHeaders>, isPreFlight: boolean): string | undefined {
    return (isPreFlight ? request.headers.one('access-control-request-method') : request.method);
}

function getHeadersToUse(request: HttpRequest, isPreFlight: boolean): string[] {
    const headers = request.headers;
    return (isPreFlight ? headers.list('access-control-request-headers') : Array.from(headers.keys()));
}
