/*
 * 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/>.
 */
// TODO: database access can be a service
const { RESOURCE_STATUS_PENDING_CREATE } = require('../modules/puterfs/ResourceService.js');
const { NodePathSelector, NodeUIDSelector, NodeInternalIDSelector, NodeSelector } = require('./node/selectors.js');
const FSNodeContext = require('./FSNodeContext.js');
const { Context } = require('../util/context.js');
const APIError = require('../api/APIError.js');
const { PermissionUtil, PermissionRewriter, PermissionImplicator, PermissionExploder } = require('../services/auth/permissionUtils.mjs');
const { DB_WRITE } = require('../services/database/consts');
const { UserActorType } = require('../services/auth/Actor');
const { get_user } = require('../helpers');
const BaseService = require('../services/BaseService');
const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs');
const { quot } = require('@heyputer/putility/src/libs/string.js');
const fsCapabilities = require('./definitions/capabilities.js');

class FilesystemService extends BaseService {
    static MODULES = {
        _path: require('path'),
        uuidv4: require('uuid').v4,
        config: require('../config.js'),
    };

    old_constructor (args) {
        const { services } = args;

        // The new fs entry service
        this.log = services.get('log-service').create('filesystem-service');

        // used by update_child_paths
        this.db = services.get('database').get(DB_WRITE, 'filesystem');

        const info = services.get('information');
        info.given('fs.fsentry').provide('fs.fsentry:path')
            .addStrategy('entry-or-delegate', async entry => {
                if ( entry.path ) return entry.path;
                return await info
                    .with('fs.fsentry:uuid')
                    .obtain('fs.fsentry:path')
                    .exec(entry.uuid);
            });
    }

    async _init () {
        this.old_constructor({ services: this.services });
        const svc_permission = this.services.get('permission');
        svc_permission.register_rewriter(PermissionRewriter.create({
            matcher: permission => {
                if ( !permission.startsWith('fs:') && !permission.startsWith('manage:fs:') ) return false;
                const [_, specifier] = permission.split('fs:');
                if ( ! specifier.startsWith('/') ) return false;
                return true;
            },
            rewriter: async permission => {
                const [manageOpt, pathPerm] = permission.split('fs:');
                const [path, ...rest] = PermissionUtil.split(pathPerm);
                const node = await this.node(new NodePathSelector(path));
                if ( ! await node.exists() ) {
                    // TOOD: we need a general-purpose error that can have
                    // a user-safe message, instead of using APIError
                    // which is for API errors.
                    throw APIError.create('subject_does_not_exist');
                }
                const uid = await node.get('uid');
                if ( uid === undefined || uid === 'undefined' ) {
                    throw new Error(`uid is undefined for path ${path}`);
                }
                return [manageOpt.replace(':', ''), 'fs', uid, ...rest].filter(Boolean).join(':');
            },
        }));
        svc_permission.register_implicator(PermissionImplicator.create({
            id: 'is-owner',
            shortcut: true,
            matcher: permission => {
                // TODO DS: for now users will only have manage access on files, that might change, and then this has to change too
                return permission.startsWith('fs:')
                    || permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`)
                    || permission.startsWith(`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`); // owner has implicit rule to give others manage access;
            },
            checker: async ({ actor, permission }) => {
                if ( ! (actor.type instanceof UserActorType) ) {
                    return undefined;
                }

                const [_, uid] = PermissionUtil.split(permission.replaceAll(`${MANAGE_PERM_PREFIX}:`, ''));
                const node = await this.node(new NodeUIDSelector(uid));

                if ( ! await node.exists() ) {
                    return undefined;
                }

                const owner_id = await node.get('user_id');

                // These conditions should never happen
                if ( !owner_id || !actor.type.user.id ) {
                    throw new Error('something unexpected happened');
                }

                if ( owner_id === actor.type.user.id ) {
                    return {};
                }

                return undefined;
            },
        }));
        svc_permission.register_exploder(PermissionExploder.create({
            id: 'fs-access-levels',
            matcher: permission => {
                return permission.startsWith('fs:') &&
                    PermissionUtil.split(permission).length >= 3;
            },
            exploder: async ({ permission }) => {
                const permissions = [permission];
                const [fsPrefix, fileId, specifiedMode, ...rest] = PermissionUtil.split(permission);

                const rules = {
                    see: ['list', 'read', 'write'],
                    list: ['read', 'write'],
                    read: ['write'],
                };

                if ( rules[specifiedMode] ) {
                    permissions.push(...rules[specifiedMode].map(mode => PermissionUtil.join(fsPrefix, fileId, mode, ...rest.slice(1))));
                    // push manage permission as well
                    permissions.push(PermissionUtil.join(MANAGE_PERM_PREFIX, fsPrefix, fileId));
                }

                return permissions;
            },
        }));
    }

    async mkshortcut ({ parent, name, user, target }) {

        // Access Control
        {
            const svc_acl = this.services.get('acl');

            if ( ! await svc_acl.check(user, target, 'read') ) {
                throw await svc_acl.get_safe_acl_error(user, target, 'read');
            }

            if ( ! await svc_acl.check(user, parent, 'write') ) {
                throw await svc_acl.get_safe_acl_error(user, parent, 'write');
            }
        }

        if ( ! await target.exists() ) {
            throw APIError.create('shortcut_to_does_not_exist');
        }

        if ( ! parent.provider.get_capabilities().has(fsCapabilities.PUTER_SHORTCUT) ) {
            throw APIError.create('missing_filesystem_capability', null, {
                action: 'make shortcut',
                subjectName: parent.path ?? parent.uid,
                providerName: parent.provider.name,
                capability: 'PUTER_SHORTCUT',
            });
        }

        return await parent.provider.puter_shortcut({
            parent, name, user, target,
        });
    }

    async mklink ({ parent, name, user, target }) {

        // Access Control
        {
            const svc_acl = this.services.get('acl');

            if ( ! await svc_acl.check(user, parent, 'write') ) {
                throw await svc_acl.get_safe_acl_error(user, parent, 'write');
            }
        }

        // We don't check if the target exists because broken links
        // are allowed.

        const { _path, uuidv4 } = this.modules;
        const resourceService = this.services.get('resourceService');
        const svc_fsEntry = this.services.get('fsEntryService');

        const ts = Math.round(Date.now() / 1000);
        const uid = uuidv4();

        resourceService.register({
            uid,
            status: RESOURCE_STATUS_PENDING_CREATE,
        });

        const raw_fsentry = {
            is_symlink: 1,
            symlink_path: target,
            is_dir: 0,
            uuid: uid,
            parent_uid: await parent.get('uid'),
            path: _path.join(await parent.get('path'), name),
            user_id: user.id,
            name,
            created: ts,
            updated: ts,
            modified: ts,
            immutable: false,
        };

        this.log.debug('creating symlink', { fsentry: raw_fsentry });

        const entryOp = await svc_fsEntry.insert(raw_fsentry);

        (async () => {
            await entryOp.awaitDone();
            this.log.debug('finished creating symlink', { uid });
            resourceService.free(uid);
        })();

        const node = await this.node(new NodeUIDSelector(uid));

        const svc_event = this.services.get('event');
        svc_event.emit('fs.create.symlink', {
            node,
            context: Context.get(),
        });

        return node;
    }

    async update_child_paths (old_path, new_path, user_id) {
        const svc_performanceMonitor = this.services.get('performance-monitor');
        const monitor = svc_performanceMonitor.createContext('update_child_paths');

        if ( ! old_path.endsWith('/') ) old_path += '/';
        if ( ! new_path.endsWith('/') ) new_path += '/';
        // TODO: fs:decouple-tree-storage
        await this.db.write('UPDATE fsentries SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path LIKE ? AND user_id = ?',
                        [new_path, old_path.length + 1, `${old_path}%`, user_id]);

        const log = this.services.get('log-service').create('update_child_paths');
        log.debug(`updated ${old_path} -> ${new_path}`);

        monitor.end();
    }

    /**
     * node() returns a filesystem node using path, uid,
     * or id associated with a filesystem node. Use this
     * method when you need to get a filesystem node and
     * need to collect information about the entry.
     *
     * @param {*} location - path, uid, or id associated with a filesystem node
     * @returns
     */
    async node (selector) {
        if ( typeof selector === 'string' ) {
            if ( selector.startsWith('/') ) {
                selector = new NodePathSelector(selector);
            }
        }

        // COERCE: legacy selection objects to Node*Selector objects
        if (
            typeof selector === 'object' &&
            selector.constructor.name === 'Object'
        ) {
            if ( selector.path ) {
                selector = new NodePathSelector(selector.path);
            } else if ( selector.uid ) {
                selector = new NodeUIDSelector(selector.uid);
            } else {
                selector = new NodeInternalIDSelector('mysql', selector.mysql_id);
            }
        }

        if ( ! (selector instanceof NodeSelector) ) {
            throw new Error(`FileSystemService could not resolve the specified node value ${
                quot(`${ selector}`) } (type: ${typeof selector}) ` +
                'to a filesystem node selector');
        }

        system_dir_check: {
            if ( ! (selector instanceof NodePathSelector) ) break system_dir_check;
            if ( ! selector.value.startsWith('/') ) break system_dir_check;

            // OPTIMIZATION: Check if the path matches a system directory pattern.
            const systemDirRegex = /^\/([a-zA-Z0-9_]+)\/(Trash|AppData|Desktop|Documents|Pictures|Videos|Public)$/;
            const match = selector.value.match(systemDirRegex);
            if ( ! match ) break system_dir_check;

            const username = match[1];
            const dirName = match[2];

            // Get the user object (this is likely cached).
            const user = await get_user({ username });
            if ( ! user ) break system_dir_check;

            let uuidKey = ( selector.value === `/${user.username}` )
                ? 'home_uuid'
                : `${dirName.toLowerCase()}_uuid`; // e.g., 'desktop_uuid'

            const cachedUUID = user[uuidKey];
            if ( ! cachedUUID ) break system_dir_check;

            // If we have a cached ID, use it for more direct lookup.
            selector = new NodeUIDSelector(cachedUUID);
        }

        const svc_mountpoint = this.services.get('mountpoint');
        const provider = await svc_mountpoint.get_provider(selector);

        let fsNode = new FSNodeContext({
            provider,
            services: this.services,
            selector,
            fs: this,
        });

        return fsNode;
    }

    /**
     * get_entry() returns a filesystem entry using
     * path, uid, or id associated with a filesystem
     * node. Use this method when you need to get a
     * filesystem entry but don't need to collect any
     * other information about the entry.
     *
     * @warning The entry returned by this method is not
     * client-safe. Use FSNodeContext to get a client-safe
     * entry by calling it's fetchEntry() method.
     *
     * @param {*} param0 options for getting the entry
     * @param {*} param0.path
     * @param {*} param0.uid
     * @param {*} param0.id please use mysql_id instead
     * @param {*} param0.mysql_id
     */
    async get_entry ({ path, uid, id, mysql_id, ...options }) {
        let fsNode = await this.node({ path, uid, id, mysql_id });
        await fsNode.fetchEntry(options);
        return fsNode.entry;
    }
}

module.exports = {
    FilesystemService,
};
