'use strict';

const Querystring = require('querystring');
const Url = require('url');

const Boom = require('@hapi/boom');
const Bounce = require('@hapi/bounce');
const Hoek = require('@hapi/hoek');
const Podium = require('@hapi/podium');

const Cors = require('./cors');
const Toolkit = require('./toolkit');
const Transmit = require('./transmit');


const internals = {
    events: Podium.validate(['finish', { name: 'peek', spread: true }, 'disconnect']),
    reserved: ['server', 'url', 'query', 'path', 'method', 'mime', 'setUrl', 'setMethod', 'headers', 'id', 'app', 'plugins', 'route', 'auth', 'pre', 'preResponses', 'info', 'isInjected', 'orig', 'params', 'paramsArray', 'payload', 'state', 'response', 'raw', 'domain', 'log', 'logs', 'generateResponse']
};


exports = module.exports = internals.Request = class {

    constructor(server, req, res, options) {

        this._allowInternals = !!options.allowInternals;
        this._closed = false;                                                                               // true once the response has closed (esp. early) and will not emit any more events
        this._core = server._core;
        this._entity = null;                                                                                // Entity information set via h.entity()
        this._eventContext = { request: this };
        this._events = null;                                                                                // Assigned an emitter when request.events is accessed
        this._expectContinue = !!options.expectContinue;
        this._isInjected = !!options.isInjected;
        this._isPayloadPending = !!(req.headers['content-length'] || req.headers['transfer-encoding']);     // Changes to false when incoming payload fully processed
        this._isReplied = false;                                                                            // true when response processing started
        this._route = this._core.router.specials.notFound.route;                                            // Used prior to routing (only settings are used, not the handler)
        this._serverTimeoutId = null;
        this._states = {};
        this._url = null;
        this._urlError = null;

        this.app = options.app ? Object.assign({}, options.app) : {};                                       // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned)
        this.headers = req.headers;
        this.logs = [];
        this.method = req.method.toLowerCase();
        this.mime = null;
        this.orig = {};
        this.params = null;
        this.paramsArray = null;                                                                            // Array of path parameters in path order
        this.path = null;
        this.payload = undefined;
        this.plugins = options.plugins ? Object.assign({}, options.plugins) : {};                           // Place for plugins to store state without conflicts with hapi, should be namespaced using plugin name (shallow cloned)
        this.pre = {};                                                                                      // Pre raw values
        this.preResponses = {};                                                                             // Pre response values
        this.raw = { req, res };
        this.response = null;
        this.route = this._route.public;
        this.query = null;
        this.server = server;
        this.state = null;

        this.info = new internals.Info(this);

        this.auth = {
            isAuthenticated: false,
            isAuthorized: false,
            isInjected: options.auth ? true : false,
            [internals.Request.symbols.authPayload]: options.auth?.payload ?? true,
            credentials: options.auth?.credentials ?? null,                                    // Special keys: 'app', 'user', 'scope'
            artifacts: options.auth?.artifacts ?? null,                                      // Scheme-specific artifacts
            strategy: options.auth?.strategy ?? null,
            mode: null,
            error: null
        };

        // Parse request url

        this._initializeUrl();
    }

    static generate(server, req, res, options) {

        const request = new server._core.Request(server, req, res, options);

        // Decorate

        if (server._core.decorations.requestApply) {
            for (const [property, assignment] of server._core.decorations.requestApply.entries()) {
                request[property] = assignment(request);
            }
        }

        request._listen();
        return request;
    }

    get events() {

        if (!this._events) {
            this._events = new Podium.Podium(internals.events);
        }

        return this._events;
    }

    get isInjected() {

        return this._isInjected;
    }

    get url() {

        if (this._urlError) {
            return null;
        }

        if (this._url) {
            return this._url;
        }

        return this._parseUrl(this.raw.req.url, this._core.settings.router);
    }

    _initializeUrl() {

        try {
            this._setUrl(this.raw.req.url, this._core.settings.router.stripTrailingSlash, { fast: true });
        }
        catch (err) {
            this.path = this.raw.req.url;
            this.query = {};

            this._urlError = Boom.boomify(err, { statusCode: 400, override: false });
        }
    }

    setUrl(url, stripTrailingSlash) {

        Hoek.assert(this.params === null, 'Cannot change request URL after routing');

        if (url instanceof Url.URL) {
            url = url.href;
        }

        Hoek.assert(typeof url === 'string', 'Url must be a string or URL object');

        this._setUrl(url, stripTrailingSlash, { fast: false });
    }

    _setUrl(source, stripTrailingSlash, { fast }) {

        const url = this._parseUrl(source, { stripTrailingSlash, _fast: fast });
        this.query = this._parseQuery(url.searchParams);
        this.path = url.pathname;
    }

    _parseUrl(source, options) {

        if (source[0] === '/') {

            // Relative URL

            if (options._fast) {
                const url = {
                    pathname: source,
                    searchParams: ''
                };

                const q = source.indexOf('?');
                const h = source.indexOf('#');

                if (q !== -1 &&
                    (h === -1 || q < h)) {

                    url.pathname = source.slice(0, q);
                    const query = h === -1 ? source.slice(q + 1) : source.slice(q + 1, h);
                    url.searchParams = Querystring.parse(query);
                }
                else {
                    url.pathname = h === -1 ? source : source.slice(0, h);
                }

                this._normalizePath(url, options);
                return url;
            }

            this._url = new Url.URL(`${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}${source}`);
        }
        else {

            // Absolute URI (proxied)

            this._url = new Url.URL(source);
            this.info.hostname = this._url.hostname;
            this.info.host = this._url.host;
        }

        this._normalizePath(this._url, options);
        this._urlError = null;

        return this._url;
    }

    _normalizePath(url, options) {

        let path = this._core.router.normalize(url.pathname);

        if (options.stripTrailingSlash &&
            path.length > 1 &&
            path[path.length - 1] === '/') {

            path = path.slice(0, -1);
        }

        url.pathname = path;
    }

    _parseQuery(searchParams) {

        let query = Object.create(null);

        // Flatten map

        if (searchParams instanceof Url.URLSearchParams) {
            for (let [key, value] of searchParams) {
                const entry = query[key];
                if (entry !== undefined) {
                    value = [].concat(entry, value);
                }

                query[key] = value;
            }
        }
        else {
            query = Object.assign(query, searchParams);
        }

        // Custom parser

        const parser = this._core.settings.query.parser;
        if (parser) {
            query = parser(query);
            if (!query ||
                typeof query !== 'object') {

                throw Boom.badImplementation('Parsed query must be an object');
            }
        }

        return query;
    }

    setMethod(method) {

        Hoek.assert(this.params === null, 'Cannot change request method after routing');
        Hoek.assert(method && typeof method === 'string', 'Missing method');

        this.method = method.toLowerCase();
    }

    active() {

        return !!this._eventContext.request;
    }

    async _execute() {

        this.info.acceptEncoding = this._core.compression.accept(this);

        try {
            await this._onRequest();
        }
        catch (err) {
            Bounce.rethrow(err, 'system');
            return this._reply(err);
        }

        this._lookup();
        this._setTimeouts();
        await this._lifecycle();
        this._reply();
    }

    async _onRequest() {

        // onRequest (can change request method and url)

        if (this._core.extensions.route.onRequest.nodes) {
            const response = await this._invoke(this._core.extensions.route.onRequest);
            if (response) {
                if (!internals.skip(response)) {
                    throw Boom.badImplementation('onRequest extension methods must return an error, a takeover response, or a continue signal');
                }

                throw response;
            }
        }

        // Validate path

        if (this._urlError) {
            throw this._urlError;
        }
    }

    _listen() {

        if (this._isPayloadPending) {
            this.raw.req.on('end', internals.event.bind(this.raw.req, this._eventContext, 'end'));
        }

        this.raw.res.on('close', internals.event.bind(this.raw.res, this._eventContext, 'close'));
        this.raw.req.on('error', internals.event.bind(this.raw.req, this._eventContext, 'error'));
        this.raw.req.on('aborted', internals.event.bind(this.raw.req, this._eventContext, 'abort'));
        this.raw.res.once('close', internals.closed.bind(this.raw.res, this));
    }

    _lookup() {

        const match = this._core.router.route(this.method, this.path, this.info.hostname);
        if (!match.route.settings.isInternal ||
            this._allowInternals) {

            this._route = match.route;
            this.route = this._route.public;
        }

        this.params = match.params ?? {};
        this.paramsArray = match.paramsArray ?? [];

        if (this.route.settings.cors) {
            this.info.cors = {
                isOriginMatch: Cors.matchOrigin(this.headers.origin, this.route.settings.cors)
            };
        }
    }

    _setTimeouts() {

        if (this.raw.req.socket &&
            this.route.settings.timeout.socket !== undefined) {

            this.raw.req.socket.setTimeout(this.route.settings.timeout.socket || 0);    // Value can be false or positive
        }

        let serverTimeout = this.route.settings.timeout.server;
        if (!serverTimeout) {
            return;
        }

        const elapsed = Date.now() - this.info.received;
        serverTimeout = Math.floor(serverTimeout - elapsed);            // Calculate the timeout from when the request was constructed

        if (serverTimeout <= 0) {
            internals.timeoutReply(this, serverTimeout);
            return;
        }

        this._serverTimeoutId = setTimeout(internals.timeoutReply, serverTimeout, this, serverTimeout);
    }

    async _lifecycle() {

        for (const func of this._route._cycle) {
            if (this._isReplied) {
                return;
            }

            try {
                var response = await (typeof func === 'function' ? func(this) : this._invoke(func));
            }
            catch (err) {
                Bounce.rethrow(err, 'system');
                response = this._core.Response.wrap(err, this);
            }

            if (!response ||
                response === Toolkit.symbols.continue) {                // Continue

                continue;
            }

            if (!internals.skip(response)) {
                response = Boom.badImplementation('Lifecycle methods called before the handler can only return an error, a takeover response, or a continue signal');
            }

            this._setResponse(response);
            return;
        }
    }

    async _invoke(event, options = {}) {

        for (const ext of event.nodes) {
            const realm = ext.realm;
            const bind = ext.bind ?? realm.settings.bind;
            const response = await this._core.toolkit.execute(ext.func, this, { bind, realm, timeout: ext.timeout, name: event.type, ignoreResponse: options.ignoreResponse });

            if (options.ignoreResponse) {
                if (Boom.isBoom(response)) {
                    this._log(['ext', 'error'], response);
                }

                continue;
            }

            if (response === Toolkit.symbols.continue) {
                continue;
            }

            if (internals.skip(response) ||
                this.response === null) {

                return response;
            }

            this._setResponse(response);
        }
    }

    async _reply(exit) {

        if (this._isReplied) {                                          // Prevent any future responses to this request
            return;
        }

        this._isReplied = true;

        if (this._serverTimeoutId) {
            clearTimeout(this._serverTimeoutId);
        }

        if (exit) {                                                     // Can be a valid response or error (if returned from an ext, already handled because this.response is also set)
            this._setResponse(this._core.Response.wrap(exit, this));    // Wrap to ensure any object thrown is always a valid Boom or Response object
        }

        if (!this._eventContext.request) {
            this._finalize();
            return;
        }

        if (typeof this.response === 'symbol') {                        // close or abandon
            this._abort();
            return;
        }

        await this._postCycle();

        if (!this._eventContext.request ||
            typeof this.response === 'symbol') {                        // close or abandon

            this._abort();
            return;
        }

        await Transmit.send(this);
        this._finalize();
    }

    async _postCycle() {

        for (const func of this._route._postCycle) {
            if (!this._eventContext.request) {
                return;
            }

            try {
                var response = await (typeof func === 'function' ? func(this) : this._invoke(func));
            }
            catch (err) {
                Bounce.rethrow(err, 'system');
                response = this._core.Response.wrap(err, this);
            }

            if (response &&
                response !== Toolkit.symbols.continue) {            // Continue

                this._setResponse(response);
            }
        }
    }

    _abort() {

        if (this.response === Toolkit.symbols.close) {
            this.raw.res.end();                                     // End the response in case it wasn't already closed
        }

        this._finalize();
    }

    _finalize() {

        this._eventContext.request = null;              // Disable req events

        if (this.response._close) {
            if (this.response.statusCode === 500 &&
                this.response._error) {

                const tags = this.response._error.isDeveloperError ? ['internal', 'implementation', 'error'] : ['internal', 'error'];
                this._log(tags, this.response._error, 'error');
            }

            this.response._close();
        }

        this.info.completed = Date.now();

        this._core.events.emit('response', this);

        if (this._route._extensions.onPostResponse.nodes) {
            this._invoke(this._route._extensions.onPostResponse, { ignoreResponse: true });
        }
    }

    _setResponse(response) {

        if (this.response &&
            !this.response.isBoom &&
            this.response !== response &&
            this.response.source !== response.source) {

            this.response._close?.();
        }

        if (this.info.completed) {
            response._close?.();

            return;
        }

        this.response = response;
    }

    _setState(name, value, options) {

        const state = { name, value };
        if (options) {
            Hoek.assert(!options.autoValue, 'Cannot set autoValue directly in a response');
            state.options = Hoek.clone(options);
        }

        this._states[name] = state;
    }

    _clearState(name, options = {}) {

        const state = { name };

        state.options = Hoek.clone(options);
        state.options.ttl = 0;

        this._states[name] = state;
    }

    _tap() {

        if (!this._events) {
            return null;
        }

        if (this._events.hasListeners('peek') ||
            this._events.hasListeners('finish')) {

            return new this._core.Response.Peek(this._events);
        }

        return null;
    }

    log(tags, data) {

        return this._log(tags, data, 'app');
    }

    _log(tags, data, channel = 'internal') {

        if (!this._core.events.hasListeners('request') &&
            !this.route.settings.log.collect) {

            return;
        }

        if (!Array.isArray(tags)) {
            tags = [tags];
        }

        const timestamp = Date.now();
        const field = data instanceof Error ? 'error' : 'data';

        let event = [this, { request: this.info.id, timestamp, tags, [field]: data, channel }];
        if (typeof data === 'function') {
            event = () => [this, { request: this.info.id, timestamp, tags, data: data(), channel }];
        }

        if (this.route.settings.log.collect) {
            if (typeof data === 'function') {
                event = event();
            }

            this.logs.push(event[1]);
        }

        this._core.events.emit({ name: 'request', channel, tags }, event);
    }

    generateResponse(source, options) {

        return new this._core.Response(source, this, options);
    }
};


internals.Request.reserved = internals.reserved;

internals.Request.symbols = {
    authPayload: Symbol('auth.payload')
};

internals.Info = class {

    constructor(request) {

        this._request = request;

        const req = request.raw.req;
        const host = req.headers.host ? req.headers.host.trim() : '';
        const received = Date.now();

        this.received = received;
        this.referrer = req.headers.referrer || req.headers.referer || '';
        this.host = host;
        this.hostname = /^(.*?)(?::\d+)?$/.exec(host)[1];
        this.id = `${received}:${request._core.info.id}:${request._core._counter()}`;

        this._remoteAddress = null;
        this._remotePort = null;

        // Assigned later

        this.acceptEncoding = null;
        this.cors = null;
        this.responded = 0;
        this.completed = 0;

        if (request._core.settings.info.remote) {
            this.remoteAddress;
            this.remotePort;
        }
    }

    get remoteAddress() {

        if (!this._remoteAddress) {
            const ipv6Prefix = '::ffff:';
            const socketAddress = this._request.raw.req.socket.remoteAddress;
            if (socketAddress && socketAddress.startsWith(ipv6Prefix) && socketAddress.includes('.', ipv6Prefix.length)) {
                // Normalize IPv4-mapped IPv6 address, e.g. ::ffff:127.0.0.1 -> 127.0.0.1
                this._remoteAddress = socketAddress.slice(ipv6Prefix.length);
            }
            else {
                this._remoteAddress = socketAddress;
            }
        }

        return this._remoteAddress;
    }

    get remotePort() {

        if (this._remotePort === null) {
            this._remotePort = this._request.raw.req.socket.remotePort || '';
        }

        return this._remotePort;
    }

    toJSON() {

        return {
            acceptEncoding: this.acceptEncoding,
            completed: this.completed,
            cors: this.cors,
            host: this.host,
            hostname: this.hostname,
            id: this.id,
            received: this.received,
            referrer: this.referrer,
            remoteAddress: this.remoteAddress,
            remotePort: this.remotePort,
            responded: this.responded
        };
    }
};


internals.closed = function (request) {

    request._closed = true;
};

internals.event = function ({ request }, event, err) {

    if (!request) {
        return;
    }

    request._isPayloadPending = false;

    if (event === 'close' &&
        request.raw.res.writableEnded) {

        return;
    }

    if (event === 'end') {
        return;
    }

    request._log(err ? ['request', 'error'] : ['request', 'error', event], err);

    if (event === 'error') {
        return;
    }

    request._eventContext.request = null;

    if (event === 'abort') {

        // Calling _reply() means that the abort is applied immediately, unless the response has already
        // called _reply(), in which case this call is ignored and the transmit logic is responsible for
        // handling the abort.

        request._reply(new Boom.Boom('Request aborted', { statusCode: request.route.settings.response.disconnectStatusCode, data: request.response }));

        if (request._events) {
            request._events.emit('disconnect');
        }
    }
};


internals.timeoutReply = function (request, timeout) {

    const elapsed = Date.now() - request.info.received;
    request._log(['request', 'server', 'timeout', 'error'], { timeout, elapsed });
    request._reply(Boom.serverUnavailable());
};


internals.skip = function (response) {

    return response.isBoom || response._takeover || typeof response === 'symbol';
};
