'use strict';

const Http = require('http');

const Ammo = require('@hapi/ammo');
const Boom = require('@hapi/boom');
const Bounce = require('@hapi/bounce');
const Hoek = require('@hapi/hoek');
const Teamwork = require('@hapi/teamwork');

const Config = require('./config');


const internals = {};


exports.send = async function (request) {

    const response = request.response;

    try {
        if (response.isBoom) {
            await internals.fail(request, response);
            return;
        }

        await internals.marshal(response);
        await internals.transmit(response);
    }
    catch (err) {
        Bounce.rethrow(err, 'system');
        request._setResponse(err);
        return internals.fail(request, err);
    }
};


internals.marshal = async function (response) {

    for (const func of response.request._route._marshalCycle) {
        await func(response);
    }
};


internals.fail = async function (request, boom) {

    const response = internals.error(request, boom);
    request.response = response;                                // Not using request._setResponse() to avoid double log

    try {
        await internals.marshal(response);
    }
    catch (err) {
        Bounce.rethrow(err, 'system');

        // Failed to marshal an error - replace with minimal representation of original error

        const minimal = {
            statusCode: response.statusCode,
            error: Http.STATUS_CODES[response.statusCode],
            message: boom.message
        };

        response._payload = new request._core.Response.Payload(JSON.stringify(minimal), {});
    }

    return internals.transmit(response);
};


internals.error = function (request, boom) {

    const error = boom.output;
    const response = new request._core.Response(error.payload, request, { error: boom });
    response.code(error.statusCode);
    response.headers = Hoek.clone(error.headers);               // Prevent source from being modified
    return response;
};


internals.transmit = function (response) {

    const request = response.request;
    const length = internals.length(response);

    // Pipes

    const encoding = request._core.compression.encoding(response, length);
    const ranger = encoding ? null : internals.range(response, length);
    const compressor = internals.encoding(response, encoding);

    // Connection: close

    const isInjection = request.isInjected;
    if (!(isInjection || request._core.started) ||
        request._isPayloadPending && !request.raw.req._readableState.ended) {

        response._header('connection', 'close');
    }

    // Write headers

    internals.writeHead(response);

    // Injection

    if (isInjection) {
        request.raw.res[Config.symbol] = { request };

        if (response.variety === 'plain') {
            request.raw.res[Config.symbol].result = response._isPayloadSupported() ? response.source : null;
        }
    }

    // Finalize response stream

    const stream = internals.chain([response._payload, response._tap(), compressor, ranger]);
    return internals.pipe(request, stream);
};


internals.length = function (response) {

    const request = response.request;

    const header = response.headers['content-length'];
    if (header === undefined) {
        return null;
    }

    let length = header;
    if (typeof length === 'string') {
        length = parseInt(header, 10);
        if (!isFinite(length)) {
            delete response.headers['content-length'];
            return null;
        }
    }

    // Empty response

    if (length === 0 &&
        !response._statusCode &&
        response.statusCode === 200 &&
        request.route.settings.response.emptyStatusCode !== 200) {

        response.code(204);
        delete response.headers['content-length'];
    }

    return length;
};


internals.range = function (response, length) {

    const request = response.request;

    if (!length ||
        !request.route.settings.response.ranges ||
        request.method !== 'get' ||
        response.statusCode !== 200) {

        return null;
    }

    response._header('accept-ranges', 'bytes');

    if (!request.headers.range) {
        return null;
    }

    // Check If-Range

    if (request.headers['if-range'] &&
        request.headers['if-range'] !== response.headers.etag) {            // Ignoring last-modified date (weak)

        return null;
    }

    // Parse header

    const ranges = Ammo.header(request.headers.range, length);
    if (!ranges) {
        const error = Boom.rangeNotSatisfiable();
        error.output.headers['content-range'] = 'bytes */' + length;
        throw error;
    }

    // Prepare transform

    if (ranges.length !== 1) {                                              // Ignore requests for multiple ranges
        return null;
    }

    const range = ranges[0];
    response.code(206);
    response.bytes(range.to - range.from + 1);
    response._header('content-range', 'bytes ' + range.from + '-' + range.to + '/' + length);

    return new Ammo.Clip(range);
};


internals.encoding = function (response, encoding) {

    const request = response.request;

    const header = response.headers['content-encoding'] || encoding;
    if (header &&
        response.headers.etag &&
        response.settings.varyEtag) {

        response.headers.etag = response.headers.etag.slice(0, -1) + '-' + header + '"';
    }

    if (!encoding ||
        response.statusCode === 206 ||
        !response._isPayloadSupported()) {

        return null;
    }

    delete response.headers['content-length'];
    response._header('content-encoding', encoding);
    const compressor = request._core.compression.encoder(request, encoding);
    if (response.variety === 'stream' &&
        typeof response._payload.setCompressor === 'function') {

        response._payload.setCompressor(compressor);
    }

    return compressor;
};


internals.pipe = function (request, stream) {

    const team = new Teamwork.Team();

    // Write payload

    const env = { stream, request, team };

    if (request._closed) {

        // The request has already been aborted - no need to wait or attempt to write.

        internals.end(env, 'aborted');
        return team.work;
    }

    const aborted = internals.end.bind(null, env, 'aborted');
    const close = internals.end.bind(null, env, 'close');
    const end = internals.end.bind(null, env, null);

    request.raw.req.on('aborted', aborted);

    request.raw.res.on('close', close);
    request.raw.res.on('error', end);
    request.raw.res.on('finish', end);

    if (stream.writeToStream) {
        stream.writeToStream(request.raw.res);
    }
    else {
        stream.on('error', end);
        stream.on('close', aborted);
        stream.pipe(request.raw.res);
    }

    return team.work;
};


internals.end = function (env, event, err) {

    const { request, stream, team } = env;

    if (!team) {                                                        // Used instead of cleaning up emitter listeners
        return;
    }

    env.team = null;

    if (request.raw.res.writableEnded) {
        request.info.responded = Date.now();

        team.attend();
        return;
    }

    if (err) {
        request.raw.res.destroy();
        request._core.Response.drain(stream);
    }

    // Update reported response to reflect the error condition

    const origResponse = request.response;
    const error = err ? Boom.boomify(err) :
        new Boom.Boom(`Request ${event}`, { statusCode: request.route.settings.response.disconnectStatusCode, data: origResponse });

    request._setResponse(error);

    // Make inject throw a disconnect error

    if (request.raw.res[Config.symbol]) {
        request.raw.res[Config.symbol].error = event ? error :
            new Boom.Boom(`Response error`, { statusCode: request.route.settings.response.disconnectStatusCode, data: origResponse });
    }

    if (event) {
        request._log(['response', 'error', event]);
    }
    else {
        request._log(['response', 'error'], err);
    }

    request.raw.res.end();                                          // Triggers injection promise resolve
    team.attend();
};


internals.writeHead = function (response) {

    const res = response.request.raw.res;
    const headers = Object.keys(response.headers);
    let i = 0;

    try {
        for (; i < headers.length; ++i) {
            const header = headers[i];
            const value = response.headers[header];
            if (value !== undefined) {
                res.setHeader(header, value);
            }
        }
    }
    catch (err) {
        for (--i; i >= 0; --i) {
            res.removeHeader(headers[i]);       // Undo headers
        }

        throw Boom.boomify(err);
    }

    if (response.settings.message) {
        res.statusMessage = response.settings.message;
    }

    try {
        res.writeHead(response.statusCode);
    }
    catch (err) {
        throw Boom.boomify(err);
    }
};


internals.chain = function (sources) {

    let from = sources[0];
    for (let i = 1; i < sources.length; ++i) {
        const to = sources[i];
        if (to) {
            from.on('close', internals.destroyPipe.bind(from, to));
            from.on('error', internals.errorPipe.bind(from, to));
            from = from.pipe(to);
        }
    }

    return from;
};


internals.destroyPipe = function (to) {

    if (!this.readableEnded && !this.errored) {
        to.destroy();
    }
};

internals.errorPipe = function (to, err) {

    to.emit('error', err);
};
