import apps from './apps.js';
import assert from 'node:assert';
import blobs from './blobs.js';
import constants from './constants.js';
import dashboard from './dashboard.js';
import logger from './logger.js';
import ejs from 'ejs';
import express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import paths from './paths.js';
import hat from './hat.js';
import http from 'node:http';
import { HttpError } from '@cloudron/connect-lastmile';
import { HttpSuccess } from '@cloudron/connect-lastmile';
import jwt from 'jsonwebtoken';
import middleware from './middleware/index.js';
import oidcServer from './oidcserver.js';
import safe from 'safetydance';
import settings from './settings.js';
import users from './users.js';
import util from 'node:util';

const { log } = logger('proxyAuth');

// heavily inspired from https://gock.net/blog/2020/nginx-subrequest-authentication-server/ and https://github.com/andygock/auth-server


let gHttpServer = null;
let gTokenSecret = null;

function jwtVerify(req, res, next) {
    const token = req.cookies.authToken;

    if (!token) return next();

    jwt.verify(token, gTokenSecret, function (error, decoded) {
        if (error) {
            log('jwtVerify: malformed token or bad signature', error.message);
            req.user = null;
        } else {
            req.user = decoded.user || null;
        }

        next();
    });
}

function basicAuth(req) {
    const CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/;
    const USER_PASS_REGEXP = /^([^:]*):(.*)$/;

    const header = req.headers.authorization;
    if (!header) return null;

    const match = CREDENTIALS_REGEXP.exec(header);
    if (!match) return null;

    const decodedHeader = Buffer.from(match[1], 'base64').toString();
    const userPass = USER_PASS_REGEXP.exec(decodedHeader);
    if (!userPass) return null;

    return { username: userPass[1], password: userPass[2] };
}

async function authorizationHeader(req, res, next) {
    const appId = req.headers['x-app-id'] || '';
    if (!appId) return next();

    if (!req.headers.authorization) return next();

    const [error, app] = await safe(apps.get(appId));
    if (error) return next(new HttpError(503, error.message));
    if (!app) return next(new HttpError(503, 'Error getting app'));

    // only if the app supports bearer auth, pass it through to the app. without this flag, anyone can access the app with Bearer auth!
    if (req.headers.authorization.startsWith('Bearer ') && app.manifest.addons.proxyAuth.supportsBearerAuth) return next(new HttpSuccess(200, {}));

    const credentials = basicAuth(req);
    if (!credentials) return next();

    if (!app.manifest.addons.proxyAuth.basicAuth) return next(); // this is a flag because this allows auth to bypass 2FA

    const verifyFunc = credentials.username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
    const [verifyError, user] = await safe(verifyFunc(credentials.username, credentials.password, appId, { skipTotpCheck: true }));
    if (verifyError) return next(new HttpError(403, 'Invalid username or password' ));

    req.user = user;
    next();
}

// someday this can be more sophisticated and check for a real browser
function isBrowser(req) {
    const userAgent = req.get('user-agent');
    if (!userAgent) return false;

    // https://github.com/docker/engine/blob/master/dockerversion/useragent.go#L18
    return !/docker|container|libpod|skopeo/i.test(userAgent);
}

// called by nginx to authorize any protected route. this route must return only 2xx or 401/403 (http://nginx.org/en/docs/http/ngx_http_auth_request_module.html)
function auth(req, res, next) {
    if (!req.user) {
        res.clearCookie('authToken');
        if (isBrowser(req)) return next(new HttpError(401, 'Unauthorized'));

        // the header has to be generated here and cannot be set in nginx config - https://forum.nginx.org/read.php?2,171461,171469#msg-171469
        res.set('www-authenticate', 'Basic realm="Cloudron"');
        return next(new HttpError(401, 'Unauthorized'));
    }

    // user is already authenticated, refresh cookie
    const token = jwt.sign({ user: req.user }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` });

    res.cookie('authToken', token, {
        httpOnly: true,
        maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
        secure: true
    });
    res.set('x-remote-user', req.user.username);
    res.set('x-remote-email', req.user.email);
    // ensure ascii in header, node will crash with ERR_INVALID_CHAR otherwise
    // eslint-disable-next-line no-control-regex
    res.set('x-remote-name', /^[\x00-\x7F]*$/.test(req.user.displayName) ? req.user.displayName : req.user.username);

    next(new HttpSuccess(200, {}));
}

const TEMPLATE_PROXYAUTH = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'proxyauth.html'), 'utf-8');
async function login(req, res, next) {
    const appId = req.headers['x-app-id'] || '';
    if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));

    const [error, app] = await safe(apps.get(appId));
    if (error) return next(new HttpError(403, 'No such app'));

    const dashboardFqdn = (await dashboard.getLocation()).fqdn;

    if (typeof req.query.redirect === 'string') {
        res.cookie('cloudronProxyAuthRedirect', req.query.redirect, {
            httpOnly: true,
            maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
            secure: true
        });
    }

    const proxyAuthClientId = `${app.id}-proxyauth`;

    const data = {
        loginUrl: `https://${dashboardFqdn}/openid/auth?client_id=${proxyAuthClientId}&scope=openid profile email&response_type=code&redirect_uri=https://${app.fqdn}/callback`,
        iconUrl: app.iconUrl ? `https://${dashboardFqdn}${app.iconUrl}` : `https://${dashboardFqdn}/img/appicon_fallback.png`,
        name: app.label || app.subdomain || app.fqdn,
        language: await settings.get(settings.LANGUAGE_KEY),
        apiOrigin: `https://${dashboardFqdn}`,
    };

    return res.send(ejs.render(TEMPLATE_PROXYAUTH, data));
}

async function callback(req, res, next) {
    if (typeof req.query.code !== 'string') return next(new HttpError(400, 'missing query argument "code"'));

    log(`callback: with code ${req.query.code}`);

    const username = await oidcServer.consumeAuthCode(req.query.code);
    if (!username) return next(new HttpError(400, 'invalid "code"'));
    req.user = await users.getByUsername(username);

    next();
}

async function authorize(req, res, next) {
    const appId = req.headers['x-app-id'] || '';
    if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));

    const [error, app] = await safe(apps.get(appId));
    if (error) return next(new HttpError(403, 'No such app' ));

    if (!apps.canAccess(app, req.user)) return next(new HttpError(403, 'Forbidden' ));

    const token = jwt.sign({ user: users.removePrivateFields(req.user) }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` });

    res.cookie('authToken', token, {
        httpOnly: true,
        maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
        secure: true
    });

    const redirect = req.cookies.cloudronProxyAuthRedirect || '/';
    res.clearCookie('cloudronProxyAuthRedirect');

    res.redirect(302, redirect);

}

async function logout(req, res, next) {
    const appId = req.headers['x-app-id'] || '';
    if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));

    const [error, app] = await safe(apps.get(appId));
    if (error) return next(new HttpError(503, error.message));

    res.clearCookie('authToken');

    // when we have no path, redirect to the login page. we cannot redirect to '/' because browsers will immediately serve up the cached page
    // if a path is set, we can assume '/' is a public page
    res.redirect(302, app.manifest.addons.proxyAuth.path ? '/' : '/login');
}

// provides webhooks for the auth wall
function initializeAuthwallExpressSync() {
    const app = express();
    const httpServer = http.createServer(app);

    const REQUEST_TIMEOUT = 10000; // timeout for all requests

    const router = new express.Router();
    router.del = router.delete; // amend router.del for readability further on

    app
        .use(middleware.timeout(REQUEST_TIMEOUT))
        .use(middleware.cookieParser())
        .use(router)
        .use(middleware.lastMile());

    router.get ('/login',     login);
    router.get ('/callback',  callback, authorize);
    router.get ('/auth',      jwtVerify, authorizationHeader, auth); // called by nginx before accessing protected page
    router.get ('/logout',    logout);
    router.post('/logout',    logout);

    return httpServer;
}

async function start() {
    assert.strictEqual(gHttpServer, null, 'Authwall is already up and running.');

    gTokenSecret = await blobs.getString(blobs.PROXY_AUTH_TOKEN_SECRET);
    if (!gTokenSecret) {
        log('start: generating new token secret');
        gTokenSecret = hat(64);
        await blobs.setString(blobs.PROXY_AUTH_TOKEN_SECRET, gTokenSecret);
    }

    gHttpServer = initializeAuthwallExpressSync();

    await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.AUTHWALL_PORT, '127.0.0.1');
}

async function stop() {
    if (!gHttpServer) return;

    await util.promisify(gHttpServer.close.bind(gHttpServer))();
    gHttpServer = null;
}

export default {
    start,
    stop
};
