'use strict';

// Load modules

const Path = require('path');

const Ammo = require('ammo');
const Boom = require('boom');
const Bounce = require('bounce');
const Hoek = require('hoek');
const Joi = require('joi');

const Etag = require('./etag');
const Fs = require('./fs');


// Declare internals

const internals = {};


internals.defaultMap = {
    gzip: '.gz'
};


internals.schema = Joi.alternatives([
    Joi.string(),
    Joi.func(),
    Joi.object({
        path: Joi.alternatives(Joi.string(), Joi.func()).required(),
        confine: Joi.alternatives(Joi.string(), Joi.boolean()).default(true),
        filename: Joi.string(),
        mode: Joi.string().valid('attachment', 'inline').allow(false),
        lookupCompressed: Joi.boolean(),
        lookupMap: Joi.object().min(1).pattern(/.+/, Joi.string()),
        etagMethod: Joi.string().valid('hash', 'simple').allow(false),
        start: Joi.number().integer().min(0).default(0),
        end: Joi.number().integer().min(Joi.ref('start'))
    })
        .with('filename', 'mode')
]);


exports.handler = function (route, options) {

    let settings = Joi.attempt(options, internals.schema, 'Invalid file handler options (' + route.path + ')');
    settings = (typeof options !== 'object' ? { path: options, confine: '.' } : settings);
    settings.confine = settings.confine === true ? '.' : settings.confine;
    Hoek.assert(typeof settings.path !== 'string' || settings.path[settings.path.length - 1] !== '/', 'File path cannot end with a \'/\':', route.path);

    const handler = (request) => {

        const path = (typeof settings.path === 'function' ? settings.path(request) : settings.path);
        return exports.response(path, settings, request);
    };

    return handler;
};


exports.load = function (path, request, options) {

    const response = exports.response(path, options, request, true);
    return internals.prepare(response);
};


exports.response = function (path, options, request, _preloaded) {

    Hoek.assert(!options.mode || ['attachment', 'inline'].indexOf(options.mode) !== -1, 'options.mode must be either false, attachment, or inline');

    if (options.confine) {
        const confineDir = Path.resolve(request.route.settings.files.relativeTo, options.confine);
        path = Path.isAbsolute(path) ? Path.normalize(path) : Path.join(confineDir, path);

        // Verify that resolved path is within confineDir
        if (path.lastIndexOf(confineDir, 0) !== 0) {
            path = null;
        }
    }
    else {
        path = Path.isAbsolute(path) ? Path.normalize(path) : Path.join(request.route.settings.files.relativeTo, path);
    }

    const source = {
        path,
        settings: options,
        stat: null,
        file: null
    };

    const prepare = _preloaded ? null : internals.prepare;

    return request.generateResponse(source, { variety: 'file', marshal: internals.marshal, prepare, close: internals.close });
};


internals.prepare = async function (response) {

    const path = response.source.path;

    if (path === null) {
        throw Boom.forbidden(null, { code: 'EACCES' });
    }

    const file = response.source.file = new Fs.File(path);

    try {
        const stat = await file.openStat('r');

        const start = response.source.settings.start || 0;
        if (response.source.settings.end !== undefined) {
            response.bytes(response.source.settings.end - start + 1);
        }
        else {
            response.bytes(stat.size - start);
        }

        if (!response.headers['content-type']) {
            response.type(response.request.server.mime.path(path).type || 'application/octet-stream');
        }

        response.header('last-modified', stat.mtime.toUTCString());

        if (response.source.settings.mode) {
            const fileName = response.source.settings.filename || Path.basename(path);
            response.header('content-disposition', response.source.settings.mode + '; filename=' + encodeURIComponent(fileName));
        }

        await Etag.apply(response, stat);

        return response;
    }
    catch (err) {
        internals.close(response);
        throw err;
    }
};


internals.marshal = async function (response) {

    if (response.source.settings.lookupCompressed &&
        !response.source.settings.start &&
        response.source.settings.end === undefined &&
        response.request.server.settings.compression !== false) {

        const lookupMap = response.source.settings.lookupMap || internals.defaultMap;
        const encoding = response.request.info.acceptEncoding;
        const extension = lookupMap.hasOwnProperty(encoding) ? lookupMap[encoding] : null;
        if (extension) {
            const precompressed = new Fs.File(`${response.source.path}${extension}`);
            let stat;
            try {
                stat = await precompressed.openStat('r');
            }
            catch (err) {
                precompressed.close();
                Bounce.ignore(err, 'boom');
            }

            if (stat) {
                response.source.file.close();
                response.source.file = precompressed;

                response.bytes(stat.size);
                response.header('content-encoding', encoding);
                response.vary('accept-encoding');
            }
        }
    }

    return internals.createStream(response);
};


internals.addContentRange = function (response) {

    const request = response.request;
    const length = response.headers['content-length'];
    let range = null;

    if (request.route.settings.response.ranges) {
        if (request.headers.range && length) {

            // Check If-Range

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

                // Check that response is not encoded once transmitted

                const mime = request.server.mime.type(response.headers['content-type'] || 'application/octet-stream');
                const encoding = (request.server.settings.compression && mime.compressible && !response.headers['content-encoding'] ? request.info.acceptEncoding : null);

                if (encoding === 'identity' || !encoding) {

                    // 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
                        range = ranges[0];
                        response.code(206);
                        response.bytes(range.to - range.from + 1);
                        response.header('content-range', 'bytes ' + range.from + '-' + range.to + '/' + length);
                    }
                }
            }
        }

        response.header('accept-ranges', 'bytes');
    }

    return range;
};


internals.createStream = function (response) {

    const source = response.source;

    Hoek.assert(source.file !== null);

    const range = internals.addContentRange(response);

    const options = {
        start: source.settings.start || 0,
        end: source.settings.end
    };

    if (range) {
        options.end = range.to + options.start;
        options.start = range.from + options.start;
    }

    return source.file.createReadStream(options);
};


internals.close = function (response) {

    if (response.source.file !== null) {
        response.source.file.close();
        response.source.file = null;
    }
};
