import apps from './apps.js';
import assert from 'node:assert';
import constants from './constants.js';
import express from 'express';
import logger from './logger.js';
import http from 'node:http';
import { HttpError } from '@cloudron/connect-lastmile';
import middleware from './middleware/index.js';
import net from 'node:net';
import path from 'node:path';
import paths from './paths.js';
import safe from 'safetydance';
import util from 'node:util';
import volumes from './volumes.js';

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


let gHttpServer = null;

async function authorizeApp(req, res, next) {
    // make the tests pass for now
    if (constants.TEST) {
        req.resources.app = { id: 'testappid' };
        return next();
    }

    const [error, app] = await safe(apps.getByIpAddress(req.socket.remoteAddress));
    if (error) return next(new HttpError(500, error));
    if (!app) return next(new HttpError(401, 'Unauthorized'));
    if (!app.manifest.addons?.docker) return next(new HttpError(401, 'Unauthorized'));

    req.resources.app = app;

    next();
}

function attachDockerRequest(req, res, next) {
    const options = {
        socketPath: '/var/run/docker.sock',
        method: req.method,
        path: req.url,
        headers: req.headers
    };

    req.dockerRequest = http.request(options, function (dockerResponse) {
        res.writeHead(dockerResponse.statusCode, dockerResponse.headers);

        // Force node to send out the headers, this is required for the /container/wait api to make the docker cli proceed
        res.write(' ');

        dockerResponse.on('error', function (error) { log('dockerResponse error: %o', error); });
        dockerResponse.pipe(res, { end: true });
    });

    req.dockerRequest.on('error', () => {}); // abort() throws

    next();
}

async function containersCreate(req, res, next) {
    safe.set(req.body, 'HostConfig.NetworkMode', 'cloudron'); // overwrite the network the container lives in
    safe.set(req.body, 'NetworkingConfig', {}); // drop any custom network configs
    safe.set(req.body, 'Labels',  Object.assign({}, safe.query(req.body, 'Labels'), { appId: req.resources.app.id, isCloudronManaged: String(false) }));    // overwrite the app id to track containers of an app
    safe.set(req.body, 'HostConfig.LogConfig', { Type: 'syslog', Config: { 'tag': req.resources.app.id, 'syslog-address': `unix://${paths.SYSLOG_SOCKET_FILE}`, 'syslog-format': 'rfc5424' }});

    const appDataDir = path.join(paths.APPS_DATA_DIR, req.resources.app.id, 'data');

    log('containersCreate: original bind mounts:', req.body.HostConfig.Binds);

    const [error, result] = await safe(volumes.list());
    if (error) return next(new HttpError(500, `Error listing volumes: ${error.message}`));

    const volumesByName = {};
    result.forEach(r => volumesByName[r.name] = r);

    const binds = [];
    for (const bind of (req.body.HostConfig.Binds || [])) { // bind is of the host:container:rw format
        if (bind.startsWith('/app/data')) {
            binds.push(bind.replace(new RegExp('^/app/data/'), appDataDir + '/'));
        } else if (bind.startsWith('/media/')) {
            const volumeName = bind.match(new RegExp('/media/([^:/]+)/?'))[1];
            const volume = volumesByName[volumeName];
            if (volume) binds.push(bind.replace(new RegExp(`^/media/${volumeName}`), volume.hostPath));
            else log(`containersCreate: dropped unknown volume ${volumeName}`);
        } else {
            req.dockerRequest.abort();
            return next(new HttpError(400, 'Binds must be under /app/data/ or /media'));
        }
    }

    log('containersCreate: rewritten bind mounts:', binds);
    safe.set(req.body, 'HostConfig.Binds', binds);

    const plainBody = JSON.stringify(req.body);
    req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
    req.dockerRequest.end(plainBody);
}

// eslint-disable-next-line no-unused-vars
function process(req, res, next) {
    // we have to rebuild the body since we consumed in in the parser
    if (req.body && Object.keys(req.body).length !== 0) {
        const plainBody = JSON.stringify(req.body);
        req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
        req.dockerRequest.end(plainBody);
    } else if (!req.readable) {
        req.dockerRequest.end();
    } else {
        req.pipe(req.dockerRequest, { end: true });
    }
}

async function start() {
    assert(gHttpServer === null, 'Already started');

    const json = express.json({ strict: true });

    // we protect container create as the app/admin can otherwise mount random paths (like the ghost file)
    // protected other paths is done by preventing install/exec access of apps using docker addon
    const router = new express.Router();
    router.post('/:version/containers/create', containersCreate);

    const proxyServer = express();

    if (constants.TEST) {
        proxyServer.use(function (req, res, next) {
            log('proxying: ' + req.method, req.url);
            next();
        });
    }

    proxyServer
        .use((req, res , next) => {
            // we store our route resources, like app,volumes,... in req.resources. Those are added in the load() routes
            req.resources = {};
            next();
        })
        .use(authorizeApp)
        .use(attachDockerRequest)
        .use(json)
        .use(router)
        .use(process)
        .use(middleware.lastMile());

    // disable slowloris prevention: https://github.com/nodejs/node/issues/47421
    gHttpServer = http.createServer({ headersTimeout: 0, requestTimeout: 0 }, proxyServer);

    // Overwrite the default 2min request timeout. This is required for large builds for example
    gHttpServer.setTimeout(60 * 60 * 1000);

    // eslint-disable-next-line no-unused-vars
    gHttpServer.on('upgrade', function (req, client, head) {
        // Create a new tcp connection to the TCP server
        const remote = net.connect('/var/run/docker.sock', function () {
            let upgradeMessage = req.method + ' ' + req.url + ' HTTP/1.1\r\n' +
                                `Host: ${req.headers.host}\r\n` +
                                'Connection: Upgrade\r\n' +
                                'Upgrade: tcp\r\n';

            if (req.headers['content-type'] === 'application/json') {
                // TODO we have to parse the immediate upgrade request body, but I don't know how
                const plainBody = '{"Detach":false,"Tty":false}\r\n';
                upgradeMessage += 'Content-Type: application/json\r\n';
                upgradeMessage += `Content-Length: ${Buffer.byteLength(plainBody)}\r\n`;
                upgradeMessage += '\r\n';
                upgradeMessage += plainBody;
            }

            upgradeMessage += '\r\n';

            // resend the upgrade event to the docker daemon, so it responds with the proper message through the pipes
            remote.write(upgradeMessage);

            // two-way pipes between client and docker daemon
            client.pipe(remote).pipe(client);
        });
    });

    log(`start: listening on 172.18.0.1:${constants.DOCKER_PROXY_PORT}`);
    await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, '172.18.0.1');
}

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

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

export default {
    start,
    stop
};
