// METADATA // {"ai-commented":{"service":"xai"}}
/*
 * Copyright (C) 2024-present Puter Technologies Inc.
 *
 * This file is part of Puter.
 *
 * Puter is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published
 * by the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
const logSeverity = (ordinal, label, esc, winst) => ({ ordinal, label, esc, winst });
const LOG_LEVEL_ERRO = logSeverity(0, 'ERRO', '31;1', 'error');
const LOG_LEVEL_WARN = logSeverity(1, 'WARN', '33;1', 'warn');
const LOG_LEVEL_INFO = logSeverity(2, 'INFO', '36;1', 'info');
const LOG_LEVEL_NOTICEME = logSeverity(3, 'NOTICE_ME', '33;1', 'error');
const LOG_LEVEL_SYSTEM = logSeverity(3, 'SYSTEM', '36;1', 'system');
const LOG_LEVEL_DEBU = logSeverity(4, 'DEBU', '37', 'debug');
const LOG_LEVEL_TICK = logSeverity(10, 'TICK', '34;1', 'info');

const winston = require('winston');
const { Context } = require('../../util/context');
const BaseService = require('../../services/BaseService');
const { stringify_log_entry } = require('./lib/log');
const { whatis } = require('../../util/langutil');
require('winston-daily-rotate-file');

const WINSTON_LEVELS = {
    system: 0,
    error: 1,
    warn: 10,
    info: 20,
    http: 30,
    verbose: 40,
    debug: 50,
    silly: 60,
};

let display_log_level = process.env.DEBUG ? 100 : 3;
const display_log_level_label = {
    0: 'ERRO',
    1: 'WARN',
    2: 'INFO',
    3: 'SYSTEM',
    4: 'DEBUG',
    100: 'ALL',
};

/**
* Represents a logging context within the LogService.
* This class is used to manage logging operations with specific context information,
* allowing for hierarchical logging structures and dynamic field additions.
* @class LogContext
*/
class LogContext {
    constructor (logService, { crumbs, fields }) {
        this.logService = logService;
        this.crumbs = crumbs;
        this.fields = fields;
    }

    sub (name, fields = {}) {
        return new LogContext(this.logService,
                        {
                            crumbs: name ? [...this.crumbs, name] : [...this.crumbs],
                            fields: { ...this.fields, ...fields },
                        });
    }

    info (message, fields, objects) {
        this.log(LOG_LEVEL_INFO, message, fields, objects);
    }
    warn (message, fields, objects) {
        this.log(LOG_LEVEL_WARN, message, fields, objects);
    }
    debug (message, fields, objects) {
        this.log(LOG_LEVEL_DEBU, message, fields, objects);
    }
    error (message, fields, objects) {
        this.log(LOG_LEVEL_ERRO, message, fields, objects);
    }
    tick (message, fields, objects) {
        this.log(LOG_LEVEL_TICK, message, fields, objects);
    }
    called (fields = {}) {
        this.log(LOG_LEVEL_DEBU, 'called', fields);
    }
    noticeme (message, fields, objects) {
        this.log(LOG_LEVEL_NOTICEME, message, fields, objects);
    }
    system (message, fields, objects) {
        this.log(LOG_LEVEL_SYSTEM, message, fields, objects);
    }

    cache (isCacheHit, identifier, fields = {}) {
        this.log(LOG_LEVEL_DEBU,
                        isCacheHit ? 'cache_hit' : 'cache_miss',
                        { identifier, ...fields });
    }

    log (log_level, message, fields = {}, objects = {}) {
        fields = { ...this.fields, ...fields };
        {
            const x = Context.get(undefined, { allow_fallback: true });
            if ( x && x.get('trace_request') ) {
                fields.trace_request = x.get('trace_request');
            }
            if ( !fields.actor && x && x.get('actor') ) {
                try {
                    fields.actor = x.get('actor');
                } catch (e) {
                    console.log('error logging actor (this is probably fine):', e);
                }
            }
        }
        for ( const k in fields ) {
            if (
                whatis(fields[k]) === 'object' &&
                typeof fields[k].toLogFields === 'function'
            ) fields[k] = fields[k].toLogFields();
        }
        if ( Context.get('injected_logger', { allow_fallback: true }) ) {
            Context.get('injected_logger').log(
                            message + (fields ? (`; fields: ${ JSON.stringify(fields)}`) : ''));
        }
        this.logService.log_(log_level,
                        this.crumbs,
                        message,
                        fields,
                        objects);
    }

    /**
    * Generates a human-readable trace ID for logging purposes.
    *
    * @returns {string} A trace ID in the format 'xxxxxx-xxxxxx' where each segment is a
    *                   random string of six lowercase letters and digits.
    */
    mkid () {
        // generate trace id
        const trace_id = [];
        for ( let i = 0; i < 2; i++ ) {
            trace_id.push(Math.random().toString(36).slice(2, 8));
        }
        return trace_id.join('-');
    }

    /**
    * Adds a trace id to this logging context for tracking purposes.
    * @returns {LogContext} The current logging context with the trace id added.
    */
    traceOn () {
        this.fields.trace_id = this.mkid();
        return this;
    }

    /**
     * Gets the log buffer maintained by the LogService. This shows the most
     * recent log entries.
     * @returns {Array} An array of log entries stored in the buffer.
     */
    get_log_buffer () {
        return this.logService.get_log_buffer();
    }
}

/**
* Timestamp in milliseconds since the epoch, used for calculating log entry duration.
*/

/**
* @class DevLogger
* @classdesc
* A development logger class designed for logging messages during development.
* This logger can either log directly to console or delegate logging to another logger.
* It provides functionality to turn logging on/off, and can optionally write logs to a file.
*
* @param {function} log - The logging function, typically `console.log` or similar.
* @param {object} [opt_delegate] - An optional logger to which log messages can be delegated.
*/
class DevLogger {
    // TODO: this should eventually delegate to winston logger
    constructor (log, opt_delegate) {
        this.log = log;
        this.off = false;
        this.recto = null;

        if ( opt_delegate ) {
            this.delegate = opt_delegate;
        }
    }
    onLogMessage (log_lvl, crumbs, message, fields, objects) {
        if ( this.delegate ) {
            this.delegate.onLogMessage(log_lvl, crumbs, message, fields, objects);
        }

        if ( this.off ) return;

        if ( !process.env.DEBUG && log_lvl.ordinal > display_log_level ) return;

        const ld = Context.get('logdent', { allow_fallback: true });
        const prefix = globalThis.dev_console_indent_on
            ? Array(ld ?? 0).fill('    ').join('')
            : '';
        this.log_(stringify_log_entry({
            prefix,
            log_lvl,
            crumbs,
            message,
            fields,
            objects,
        }));
    }

    log_ (text) {
        if ( this.recto ) {
            const fs = require('node:fs');
            fs.appendFileSync(this.recto, `${text }\n`);
        }
        this.log(text);
    }
}

/**
* @class NullLogger
* @description A logger that does nothing, effectively disabling logging.
* This class is used when logging is not desired or during development
* to avoid performance overhead or for testing purposes.
*/
class NullLogger {
    // TODO: this should eventually delegate to winston logger
    constructor (log, opt_delegate) {
        this.log = log;

        if ( opt_delegate ) {
            this.delegate = opt_delegate;
        }
    }
    onLogMessage () {
    }
}

/**
* WinstonLogger Class
*
* A logger that delegates log messages to a Winston logger instance.
*/
class WinstonLogger {
    constructor (winst) {
        this.winst = winst;
    }
    onLogMessage (log_lvl, crumbs, message, fields, objects) {
        this.winst.log({
            ...fields,
            label: crumbs.join('.'),
            level: log_lvl.winst,
            message,
        });
    }
}

/**
* @class TimestampLogger
* @classdesc A logger that adds timestamps to log messages before delegating them to another logger.
* This class wraps another logger instance to ensure that all log messages include a timestamp,
* which can be useful for tracking the sequence of events in a system.
*
* @param {Object} delegate - The logger instance to which the timestamped log messages are forwarded.
*/
class TimestampLogger {
    constructor (delegate) {
        this.delegate = delegate;
    }
    onLogMessage (log_lvl, crumbs, message, fields, ...a) {
        fields = { ...fields, timestamp: new Date() };
        this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a);
    }
}

/**
* The `BufferLogger` class extends the logging functionality by maintaining a buffer of log entries.
* This class is designed to:
* - Store a specified number of recent log messages.
* - Allow for retrieval of these logs for debugging or monitoring purposes.
* - Ensure that the log buffer does not exceed the defined size by removing older entries when necessary.
* - Delegate logging messages to another logger while managing its own buffer.
*/
class BufferLogger {
    constructor (size, delegate) {
        this.size = size;
        this.delegate = delegate;
        this.buffer = [];
    }
    onLogMessage (log_lvl, crumbs, message, fields, ...a) {
        this.buffer.push({ log_lvl, crumbs, message, fields, ...a });
        if ( this.buffer.length > this.size ) {
            this.buffer.shift();
        }
        this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a);
    }
}

/**
* Represents a custom logger that can modify log messages before they are passed to another logger.
* @class CustomLogger
* @extends {Object}
* @param {Object} delegate - The delegate logger to which modified log messages will be passed.
* @param {Function} callback - A callback function that modifies log parameters before delegation.
*/
class CustomLogger {
    constructor (delegate, callback) {
        this.delegate = delegate;
        this.callback = callback;
    }
    async onLogMessage (log_lvl, crumbs, message, fields, ...a) {
        // Logging is allowed to be performed without a context, but we
        // don't want log functions to be asynchronous which rules out
        // wrapping with Context.allow_fallback. Instead we provide a
        // context as a parameter.
        const context = Context.get(undefined, { allow_fallback: true });

        let ret;
        try {
            ret = await this.callback({
                context,
                log_lvl,
                crumbs,
                message,
                fields,
                args: a,
            });
        } catch (e) {
            console.error('error?', e);
        }

        if ( ret && ret.skip ) return;

        if ( ! ret ) {
            this.delegate.onLogMessage(log_lvl,
                            crumbs,
                            message,
                            fields,
                            ...a);
            return;
        }

        const {
            log_lvl: _log_lvl,
            crumbs: _crumbs,
            message: _message,
            fields: _fields,
            args,
        } = ret;

        this.delegate.onLogMessage(_log_lvl ?? log_lvl,
                        _crumbs ?? crumbs,
                        _message ?? message,
                        _fields ?? fields,
                        ...(args ?? a ?? []));
    }
}

/**
* The `LogService` class extends `BaseService` and is responsible for managing and
* orchestrating various logging functionalities within the application. It handles
* log initialization, middleware registration, log directory management, and
* provides methods for creating log contexts and managing log output levels.
*/
class LogService extends BaseService {
    static MODULES = {
        path: require('path'),
    };
    /**
    * Defines the modules required by the LogService class.
    * This static property contains modules that are used for file path operations.
    * @property {Object} MODULES - An object containing required modules.
    * @property {Object} MODULES.path - The Node.js path module for handling and resolving file paths.
    */
    async _construct () {
        this.loggers = [];
        this.bufferLogger = null;
    }

    /**
     * Registers a custom logging middleware with the LogService.
     * @param {*} callback - The callback function that modifies log parameters before delegation.
     */
    register_log_middleware (callback) {
        this.loggers[0] = new CustomLogger(this.loggers[0], callback);
    }

    /**
     * Registers logging commands with the command service.
     */
    ['__on_boot.consolidation'] () {
        const commands = this.services.get('commands');
        commands.registerCommands('logs', [
            {
                id: 'show',
                description: 'toggle log output',
                handler: async (args, log) => {
                    this.devlogger && (this.devlogger.off = !this.devlogger.off);
                },
            },
            {
                id: 'rec',
                description: 'start recording to a file via dev logger',
                handler: async (args, ctx) => {
                    const [name] = args;
                    const { log } = ctx;
                    if ( ! this.devlogger ) {
                        log('no dev logger; what are you doing?');
                    }
                    this.devlogger.recto = name;
                },
            },
            {
                id: 'stop',
                description: 'stop recording to a file via dev logger',
                handler: async ([name], log) => {
                    if ( ! this.devlogger ) {
                        log('no dev logger; what are you doing?');
                    }
                    this.devlogger.recto = null;
                },
            },
            {
                id: 'indent',
                description: 'toggle log indentation',
                handler: async (args, log) => {
                    globalThis.dev_console_indent_on =
                        !globalThis.dev_console_indent_on;
                },
            },
            {
                id: 'get-level',
                description: 'get the current log level for displayed logs',
                handler: async (args, log) => {
                    log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
                },
            },
            {
                id: 'set-level',
                description: 'set the new log level for displayed logs',
                handler: async (args, log) => {
                    display_log_level = Number(args[0]);
                    log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
                },
            },
        ]);
    }
    /**
    * Registers logging commands with the command service.
    *
    * This method sets up various logging commands that can be used to
    * interact with the log output, such as toggling log display,
    * starting/stopping log recording, and toggling log indentation.
    *
    * @memberof LogService
    */
    async _init () {
        const config = this.global_config;

        this.ensure_log_directory_();

        let logger;

        if ( ! config.no_winston )
        {
            logger = new WinstonLogger(winston.createLogger({
                levels: WINSTON_LEVELS,
                transports: [
                    new winston.transports.DailyRotateFile({
                        filename: `${this.log_directory}/%DATE%.log`,
                        datePattern: 'YYYY-MM-DD',
                        zippedArchive: true,
                        maxSize: '20m',

                    // TODO: uncomment when we have a log backup strategy
                    // maxFiles: '14d',
                    }),
                    new winston.transports.DailyRotateFile({
                        level: 'error',
                        filename: `${this.log_directory}/error-%DATE%.log`,
                        datePattern: 'YYYY-MM-DD',
                        zippedArchive: true,
                        maxSize: '20m',

                    // TODO: uncomment when we have a log backup strategy
                    // maxFiles: '14d',
                    }),
                    new winston.transports.DailyRotateFile({
                        level: 'system',
                        filename: `${this.log_directory}/system-%DATE%.log`,
                        datePattern: 'YYYY-MM-DD',
                        zippedArchive: true,
                        maxSize: '20m',

                    // TODO: uncomment when we have a log backup strategy
                    // maxFiles: '14d',
                    }),
                ],
            }));
        }

        if ( config.env === 'dev' ) {
            logger = config.flag_no_logs // useful for profiling
                ? new NullLogger()
                : new DevLogger(console.log.bind(console), logger);

            this.devlogger = logger;
        }

        logger = new TimestampLogger(logger);

        logger = new BufferLogger(config.log_buffer_size ?? 20, logger);
        this.bufferLogger = logger;

        this.loggers.push(logger);

        this.output_lvl = LOG_LEVEL_INFO;
        if ( config.logger ) {
            // config.logger.level is a string, e.g. 'debug'

            // first we find the appropriate log level
            const output_lvl = Object.values({
                LOG_LEVEL_ERRO,
                LOG_LEVEL_WARN,
                LOG_LEVEL_INFO,
                LOG_LEVEL_DEBU,
                LOG_LEVEL_TICK,
            }).find(lvl => {
                return lvl.label === config.logger.level.toUpperCase() ||
                    lvl.winst === config.logger.level.toLowerCase() ||
                    lvl.ordinal === config.logger.level;
            });

            // then we set the output level to the ordinal of that level
            this.output_lvl = output_lvl.ordinal;
        }

        this.log = this.create('log-service');
        this.log.system('log service started');
        this.log.debug('log service configuration', {
            output_lvl: this.output_lvl,
            log_directory: this.log_directory,
        });

        this.services.logger = this.create('services-container');
        globalThis.root_context.set('logger', this.create('root-context'));

        {
            const util = require('util');
            const logger = this.create('console');

            if ( ! globalThis.original_console_object ) {
                globalThis.original_console_object = console;
            }

            // Keep console prototype
            const logconsole = Object.create(console);

            // Override simple log functions
            const logfn = level => (...a) => {
                logger[level](a.map(arg => {
                    if ( typeof arg === 'string' ) return arg;
                    return util.inspect(arg, undefined, undefined, true);
                }).join(' '));
            };
            logconsole.log = logfn('info');
            logconsole.warn = logfn('warn');
            logconsole.error = logfn('error');

            globalThis.console = logconsole;
        }
    }

    /**
     * Create a new log context with the specified prefix
     *
     * @param {1} prefix - The prefix for the log context
     * @param {*} fields - Optional fields to include in the log context
     * @returns {LogContext} A new log context with the specified prefix and fields
     */
    create (prefix, fields = {}) {
        const logContext = new LogContext(this,
                        {
                            crumbs: [prefix],
                            fields,
                        });

        return logContext;
    }

    log_ (log_lvl, crumbs, message, fields, objects) {
        try {
            // skip messages that are above the output level
            if ( log_lvl.ordinal > this.output_lvl ) return;

            if ( this.config.trace_logs ) {
                fields.stack = (new Error('logstack')).stack;
            }

            for ( const logger of this.loggers ) {
                logger.onLogMessage(log_lvl, crumbs, message, fields, objects);
            }
        } catch (e) {
            // If logging fails, we don't want anything to happen
            // that might trigger a log message. This causes an
            // infinite loop and I learned that the hard way.
            console.error('Logging failed', e);

            // TODO: trigger an alarm either in a non-logging
            // context (prereq: per-context service overrides)
            // or with a cooldown window (prereq: cooldowns in AlarmService)
        }
    }

    /**
    * Ensures that a log directory exists for logging purposes.
    * This method attempts to create or locate a directory for log files,
    * falling back through several predefined paths if the preferred
    * directory does not exist or cannot be created.
    *
    * @throws {Error} If no suitable log directory can be found or created.
    */
    ensure_log_directory_ () {
        // STEP 1: Try /var/puter/logs/heyputer
        {
            const fs = require('fs');
            const path = '/var/puter/logs/heyputer';
            // Making this directory if it doesn't exist causes issues
            // for users running with development instructions
            if ( ! fs.existsSync('/var/puter') ) {
                return;
            }
            try {
                fs.mkdirSync(path, { recursive: true });
                this.log_directory = path;
                return;
            } catch (e) {
                // ignore
            }
        }

        // STEP 2: Try /tmp/heyputer
        {
            const fs = require('fs');
            const path = '/tmp/heyputer';
            try {
                fs.mkdirSync(path, { recursive: true });
                this.log_directory = path;
                return;
            } catch (e) {
                // ignore
            }
        }

        // STEP 3: Try working directory
        {
            const fs = require('fs');
            const path = './heyputer';
            try {
                fs.mkdirSync(path, { recursive: true });
                this.log_directory = path;
                return;
            } catch (e) {
                // ignore
            }
        }

        // STEP 4: Give up
        throw new Error('Unable to create or find log directory');
    }

    /**
    * Generates a sanitized file path for log files.
    *
    * @param {string} name - The name of the log file, which will be sanitized to remove any path characters.
    * @returns {string} A sanitized file path within the log directory.
    */
    get_log_file (name) {
        // sanitize name: cannot contain path characters
        name = name.replace(/[^a-zA-Z0-9-_]/g, '_');
        return this.modules.path.join(this.log_directory, name);
    }

    /**
     * Get the most recent log entries from the buffer maintained by the LogService.
     * By default, the buffer contains the last 20 log entries.
     * @returns
     */
    get_log_buffer () {
        return this.bufferLogger.buffer;
    }
}

module.exports = {
    LogService,
    stringify_log_entry,
};