import { Application, default as express, NextFunction, Request, Response, Router, Router as router } from "express";
import { ProvisioningStore } from "./store";
import { Server } from "http";
import { ErrCode, IApiError, ProvisioningRequest, ApiError } from ".";
import { URL } from "url";
import { MatrixHostResolver } from "../utils/matrix-host-resolver";
import { isIP } from "net";
import { promises as dns } from "dns";
import ratelimiter, { Options as RatelimitOptions } from "express-rate-limit";
import { Methods } from "./request";
import { Logger } from "..";
import { randomUUID } from "crypto";
import IPCIDR from "ip-cidr";

// Borrowed from
// https://github.com/matrix-org/synapse/blob/91221b696156e9f1f9deecd425ae58af03ebb5d3/docs/sample_config.yaml#L215
export const DefaultDisallowedIpRanges = [
    '127.0.0.0/8',
    '10.0.0.0/8',
    '172.16.0.0/12',
    '192.168.0.0/16',
    '100.64.0.0/10',
    '192.0.0.0/24',
    '169.254.0.0/16',
    '192.88.99.0/24',
    '198.18.0.0/15',
    '192.0.2.0/24',
    '198.51.100.0/24',
    '203.0.113.0/24',
    '224.0.0.0/4',
    '::1/128',
    'fe80::/10',
    'fc00::/7',
    '2001:db8::/32',
    'ff00::/8',
    'fec0::/10'
]

const log = new Logger("ProvisioningApi");

interface ExpRequestProvisioner extends Request {
    matrixWidgetToken?: string;
    matrixUserId: string|null;
}

export interface ExchangeOpenAPIRequestBody {
    openIdToken: string;
    matrixServer: string;
}

export interface ExchangeOpenAPIResponseBody {
    token: string;
    userId: string;
}

export interface ProvisioningApiOpts {
    /**
     * A set of Matrix server names to override the well known response to. Should
     * only be used for testing.
     */
    openIdOverride?: {[serverName: string]: URL},
    /**
     * Allow these IP ranges to be hit when handling OpenID requests even if they are within
     * `disallowedIpRanges`. This allows specific sub-ranges of `disallowedIpRanges` to be
     * used without having to carefully construct the ranges that still should be disallowed.
     *
     * If the IP the OpenID request would be made to isn't in either list it is implicitly allowed.
     *
     * Defaults to the empty list.
     */
    allowedIpRanges?: string[];
    /**
     * Disallow these IP ranges from being hit when handling OpenID requests. By default, a number of
     * intenal ranges are blocked.
     * @see DefaultDisallowedIpRanges
     */
    disallowedIpRanges?: string[];
    /**
     * Secret token for provisioning requests
     */
    provisioningToken?: string;
    /**
     * For widget tokens, use this prefix.
     */
    widgetTokenPrefix?: string;
    /**
     * How long should a widget token last for?
     */
    widgetTokenLifetimeMs?: number;
    /**
     * Where are the files stored for the widget frontend. If undefined, do not host a frontend.
     */
    widgetFrontendLocation?: string;
    /**
     * Provide an existing express app to bind to.
     *
     * Note: start() and close() will no-op when this is used.
     */
    expressApp?: Application;
    /**
     * Prefix to use for the API. E.g. `/api` in `/api/v1/session`
     *
     * Default is `/api`.
     */
    apiPrefix?: string;

    /**
     * Options for ratelimiting requests to the api server. Does not affect
     * static content loading.
     */
    ratelimit?: boolean|Partial<RatelimitOptions>;
}


const DEFAULT_WIDGET_TOKEN_PREFIX = "br-sdk-utoken-";
const DEFAULT_WIDGET_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000; // One day

/**
 * The provisioning API serves two classes of clients:
 *  - Integration managers which provide a unique secret token, and a userId
 *  - Widget users which provide a openId token.
 */
export class ProvisioningApi {
    private app: Application;
    private server?: Server;
    protected baseRoute: Router;
    private readonly widgetTokenPrefix: string;
    private readonly widgetTokenLifetimeMs: number;
    private readonly wellknown = new MatrixHostResolver();
    private readonly allowedIpRanges: IPCIDR[];
    private readonly disallowedIpRanges: IPCIDR[];
    constructor(protected store: ProvisioningStore, private opts: ProvisioningApiOpts = {}) {
        this.app = express();
        this.app.use((req, _res, next) => {
            log.info(`${req.method} ${req.path} ${req.ip || ''} ${req.headers["user-agent"] || ''}`);
            next();
        });

        this.widgetTokenPrefix = opts.widgetTokenPrefix || DEFAULT_WIDGET_TOKEN_PREFIX;
        this.widgetTokenLifetimeMs = opts.widgetTokenLifetimeMs || DEFAULT_WIDGET_TOKEN_LIFETIME_MS;
        this.opts.apiPrefix = opts.apiPrefix || "/provisioning";
        this.allowedIpRanges = (opts.allowedIpRanges || []).map(ip => new IPCIDR(ip));
        this.disallowedIpRanges = (opts.disallowedIpRanges || DefaultDisallowedIpRanges).map(ip => new IPCIDR(ip));
        this.app.get('/health', this.getHealth.bind(this));

        const limiter = this.opts.ratelimit && ratelimiter({
            handler: (req, _res, next, options) => {
                next(new ApiError(
                    "Too many requests",
                    ErrCode.Ratelimited,
                    429,
                    {
                        retry_after_ms: options.windowMs,
                    }
                ));
            },
            windowMs: 1 * 60 * 1000, // 1 minute
            max: 30, // Limit per window
            standardHeaders: true,
            legacyHeaders: false,
            ...(typeof this.opts.ratelimit === "object" ? this.opts.ratelimit : {})
        });

        this.baseRoute = router();
        if (opts.widgetFrontendLocation) {
            this.baseRoute.use('/v1/static', express.static(opts.widgetFrontendLocation));
        }

        if (limiter) {
            this.baseRoute.use(limiter);
        }

        this.baseRoute.use((req: express.Request, res: express.Response, next: NextFunction) => {
            res.header('Access-Control-Allow-Origin', '*');
            res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
            next();
        });

        this.baseRoute.use(express.json());
        // Unsecured requests
        this.baseRoute.post(
            `/v1/exchange_openid`,
            (req, res, next) => this.postExchangeOpenId(req, res).catch(ex => next(ex))
        );

        // Secure requests
        // addRoute ensures all successful requests are of type ProvisioningRequest
        this.baseRoute.use((req, res, next) => this.authenticateRequest(req, res, next).catch(ex => next([ex, req])));
        this.addRoute("get", "/v1/session", this.getSession.bind(this));
        this.addRoute("delete", "/v1/session", this.deleteSession.bind(this));
        this.addRoute("delete", "/v1/session/all", this.deleteAllSessions.bind(this));
        this.baseRoute.use(this.onError);

        if (this.opts.expressApp) {
            this.opts.expressApp.use(this.opts.apiPrefix, this.baseRoute);
        }
        else {
            this.app.use(this.opts.apiPrefix, this.baseRoute);
        }
    }

    public async start(port: number, hostname = "0.0.0.0", backlog = 10): Promise<void> {
        if (this.opts.expressApp) {
            log.warn(`Ignoring call to start(), api configured to use parent express app`);
            return undefined;
        }
        return new Promise<void>((res) => {
            this.server = this.app.listen(port, hostname, backlog, () => res());
            log.info(`Widget API listening on port ${port}`);
        });
    }

    public close(): Promise<void> {
        return new Promise((res, rej) => this.server?.close(e => e ? rej(e) : res()));
    }

    public addRoute(
        method: Methods,
        path: string,
        handler: (req: ProvisioningRequest, res: Response, next?: NextFunction) => void|Promise<void>,
        fnName?: string): void {
        this.baseRoute[method](path, async (req: Express.Request, res: Response, next: NextFunction) => {
            const expRequest = req as ExpRequestProvisioner;
            const provisioningRequest = new ProvisioningRequest(
                expRequest,
                expRequest.matrixUserId,
                expRequest.matrixWidgetToken ? "widget" : "provisioner",
                expRequest.matrixWidgetToken,
                fnName,
            );
            try {
                await handler(provisioningRequest, res, next);
            }
            catch (ex) {
                // Pass to error handler.
                next([ex, provisioningRequest]);
            }
            // Always add an error handler
        }, this.onError);
    }

    private async authenticateRequest(
        // Historically, user_id has been used. The bridge library supports either.
        // eslint-disable-next-line camelcase
        req: Request<unknown, unknown, {userId?: string, user_id?: string}>, res: Response, next: NextFunction) {
        const authHeader = req.header("Authorization");
        if (!authHeader) {
            throw new ApiError('No Authorization header', ErrCode.BadToken);
        }
        const token = authHeader.replace("Bearer ", "").replace("bearer ", "");
        if (!token) {
            throw new ApiError('Invalid Authorization header format', ErrCode.BadToken);
        }
        const requestProv = (req as ExpRequestProvisioner);
        if (!this.opts.provisioningToken && req.body.userId) {
            throw new ApiError('Provisioning feature disabled', ErrCode.DisabledFeature);
        }
        if (token === this.opts.provisioningToken) {
            // Integration managers splice in the user_id in the body.
            // Sometimes it's not required though.
            requestProv.matrixUserId = req.body?.userId || req.body?.user_id || null;
            requestProv.matrixWidgetToken = undefined;
            next();
            return;
        }
        const session = await this.store.getSessionForToken(token);
        if (!session) {
            throw new ApiError('Token not found', ErrCode.BadToken);
        }
        if (session.expiresTs < Date.now()) {
            await this.store.deleteSession(token);
            throw new ApiError('Token expired', ErrCode.BadToken);
        }

        requestProv.matrixUserId = session.userId;
        requestProv.matrixWidgetToken = token;
        next();
    }

    private getHealth(req: Request, res: Response) {
        res.send({ok: true});
    }

    private getSession(req: ProvisioningRequest, res: Response) {
        res.send({
            userId: req.userId,
            type: req.requestSource,
        });
    }

    private async deleteSession(req: ProvisioningRequest, res: Response) {
        if (!req.widgetToken) {
            req.log.debug("tried to delete non-existent session");
            throw new ApiError("Session cannot be deleted", ErrCode.UnsupportedOperation);
        }
        try {
            await this.store.deleteSession(req.widgetToken);
        }
        catch (ex) {
            req.log.error("Failed to delete session", ex);
            throw new ApiError("Session could not be deleted", ErrCode.Unknown);
        }
        res.send({ok: true});
    }

    private async deleteAllSessions(req: ProvisioningRequest, res: Response) {
        if (!req.widgetToken) {
            req.log.debug("tried to delete non-existent session");
            throw new ApiError("Session cannot be deleted", ErrCode.UnsupportedOperation);
        }
        if (!req.userId) {
            throw new ApiError("")
        }
        try {
            await this.store.deleteAllSessions(req.userId);
        }
        catch (ex) {
            req.log.error("Failed to delete all sessions", ex);
            throw new ApiError("Sessions could not be deleted", ErrCode.Unknown);
        }
        res.send({ok: true});
    }

    private async checkIpBlacklist(url: URL) {
        const host = url.hostname;
        let ip: string;
        if (isIP(host)) {
            ip = host;
        }
        else {
            const record = await dns.lookup(host);
            ip = record.address;
        }

        if (this.allowedIpRanges.find(d => d.contains(ip))) {
            return;
        }

        if (this.disallowedIpRanges.find(d => d.contains(ip))) {
            throw new ApiError('Server is disallowed', ErrCode.BadOpenID);
        }
    }

    private async postExchangeOpenId(
        req: Request<unknown, unknown, ExchangeOpenAPIRequestBody>, res: Response<ExchangeOpenAPIResponseBody>) {
        const server = req.body?.matrixServer;
        const openIdToken = req.body?.openIdToken;
        if (typeof server !== "string") {
            throw new ApiError("Missing/invalid matrixServer in body", ErrCode.BadValue);
        }
        if (typeof openIdToken !== "string") {
            throw new ApiError("Missing/invalid openIdToken in body", ErrCode.BadValue);
        }
        let url: URL;
        let hostHeader: string;
        try {
            const overrideUrl = this.opts.openIdOverride?.[server];
            if (overrideUrl) {
                url = overrideUrl;
                hostHeader = server;
            }
            else {
                const urlRes = await this.wellknown.resolveMatrixServer(server);
                hostHeader = urlRes.hostHeader;
                url = urlRes.url;
                await this.checkIpBlacklist(url);
            }
        }
        catch (ex) {
            log.warn(`Failed to fetch the server URL for ${server}`, ex);
            throw new ApiError("Could not identify server url", ErrCode.BadOpenID);
        }

        // Now do the token exchange
        try {
            const requestUrl = new URL("/_matrix/federation/v1/openid/userinfo", url);
            requestUrl.searchParams.set('access_token', openIdToken);
            const response = await fetch(requestUrl, {
                headers: {
                    'Host': hostHeader,
                }
            });
            if (!response.ok) {
                log.warn(`Server responded with a status of ${response.status}`);
                log.debug(`Server response:`, await response.text());
                throw new ApiError("Server did not respond positively to request", ErrCode.BadOpenID);
            }
            const data = await response.json() as { sub: string };
            if (!data.sub) {
                log.warn(`Server responded with invalid sub information for ${server}`, data);
                throw new ApiError("Server did not respond with the correct sub information", ErrCode.BadOpenID);
            }
            const userId = data.sub;

            const mxidMatch = userId.match(/([^:]+):(.+)/);
            if (!mxidMatch) {
                throw new ApiError("Server did not respond with a valid MXID", ErrCode.BadOpenID);
            }
            const [,, serverName] = mxidMatch;
            if (serverName !== server) {
                throw new ApiError("Server returned a MXID belonging to another homeserver", ErrCode.BadOpenID);
            }

            const token = this.widgetTokenPrefix + randomUUID().replace(/-/g, "");
            const expiresTs = Date.now() + this.widgetTokenLifetimeMs;
            await this.store.createSession({
                userId,
                token,
                expiresTs,
            });
            res.send({ token, userId });
        }
        catch (ex) {
            log.warn(`Failed to exchange the token for ${server}`, ex);
            if (ex instanceof ApiError) {
                throw ex;
            }
            else {
                throw new ApiError("Failed to exchange token", ErrCode.BadOpenID);
            }
        }
    }

    // Needed so that _next can be defined in order to preserve signature.
    protected onError(
        err: [IApiError|Error, ProvisioningRequest|Request]|IApiError|Error,
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        _req: Request, res: Response, _next: NextFunction) {
        if (!err) {
            return;
        }
        const [error, request] = Array.isArray(err) ? err : [err, undefined];
        if (request instanceof ProvisioningRequest) {
            request.log.error(error);
        }
        else {
            log.error(error);
        }
        if (res.headersSent) {
            return;
        }
        if ("apply" in error && typeof error.apply === "function") {
            error.apply(res);
        }
        else {
            new ApiError("An internal error occured").apply(res);
        }
    }
}
