<?php
/**
 * Upgrades to version 6.0.0-b4
 *
 * @package blesta
 * @subpackage components.upgrades.tasks
 * @copyright Copyright (c) 2026, Phillips Data, Inc.
 * @license http://www.blesta.com/license/ The Blesta License Agreement
 * @link http://www.blesta.com/ Blesta
 */
class Upgrade6_0_0B4 extends UpgradeUtil
{
    /**
     * @var array An array of all tasks completed
     */
    private $tasks = [];

    /**
     * Setup
     */
    public function __construct()
    {
        Loader::loadComponents($this, ['Record']);
    }

    /**
     * Returns a numerically indexed array of tasks to execute for the upgrade process
     *
     * @return array A numerically indexed array of tasks to execute for the upgrade process
     */
    public function tasks()
    {
        return [
            'addClientNotesEncryptionSupport',
            'addClientNotesDecryptPermission',
            'updateUserAvatarPaths',
        ];
    }

    /**
     * Processes the given task
     *
     * @param string $task The task to process
     */
    public function process($task)
    {
        $tasks = $this->tasks();

        // Ensure task exists
        if (!in_array($task, $tasks)) {
            return;
        }

        $this->tasks[] = $task;
        $this->{$task}();
    }

    /**
     * Rolls back all tasks completed for the upgrade process
     */
    public function rollback()
    {
        // Undo all tasks
        while (($task = array_pop($this->tasks))) {
            $this->{$task}(true);
        }
    }

    /**
     * Adds the schema needed to store passphrase-encrypted client note bodies: an
     * "encrypted" flag and an optional plaintext "passphrase_hint", and widens
     * "description" to MEDIUMTEXT so the base64 + HMAC overhead of an encrypted body
     * cannot silently truncate the ciphertext. Fresh installs reach this via the full
     * upgrade chain.
     *
     * @param bool $undo Whether to undo the upgrade
     */
    private function addClientNotesEncryptionSupport($undo = false)
    {
        if ($undo) {
            $this->Record->query('ALTER TABLE `client_notes` DROP `passphrase_hint`;');
            $this->Record->query('ALTER TABLE `client_notes` DROP `encrypted`;');
            $this->Record->query('ALTER TABLE `client_notes` MODIFY `description` TEXT;');
        } else {
            $this->Record->query(
                "ALTER TABLE `client_notes` ADD `encrypted` TINYINT(1) NOT NULL DEFAULT '0' AFTER `stickied`;"
            );
            $this->Record->query(
                'ALTER TABLE `client_notes` ADD `passphrase_hint` VARCHAR(255) NULL DEFAULT NULL AFTER `encrypted`;'
            );
            $this->Record->query('ALTER TABLE `client_notes` MODIFY `description` MEDIUMTEXT;');
        }
    }

    /**
     * Registers the "admin_clients::decryptnote" permission and seeds each staff group's
     * ACL for it, mirroring that group's existing access to the client notes list
     * ("admin_clients::notes"). This is what makes the note-decrypt endpoint reachable for
     * groups whose "admin_clients" access is governed by individually-checked permissions
     * rather than a wildcard allow. Fresh installs reach this via the full upgrade chain.
     *
     * @param bool $undo Whether to undo the upgrade
     */
    private function addClientNotesDecryptPermission($undo = false)
    {
        Loader::loadModels($this, ['Permissions']);
        Loader::loadComponents($this, ['Acl']);

        $action = 'decryptnote';

        if ($undo) {
            // Removing the permission also removes its ACL entries for all AROs
            $permission = $this->Permissions->getByAlias('admin_clients', null, $action);
            if ($permission) {
                $this->Permissions->delete($permission->id);
            }

            return;
        }

        // Fetch all staff groups across every company
        $staff_groups = $this->Record->select(['staff_groups.*'])
            ->from('staff_groups')
            ->group(['staff_groups.id'])
            ->fetchAll();

        // Snapshot each staff group's current access to the notes list so the new
        // permission mirrors it
        $staff_group_access = [];
        foreach ($staff_groups as $staff_group) {
            $staff_group_access[$staff_group->id] = $this->Permissions->authorized(
                'staff_group_' . $staff_group->id,
                'admin_clients',
                'notes',
                'staff',
                $staff_group->company_id
            );
        }

        $group = $this->Permissions->getGroupByAlias('admin_clients');

        if ($group) {
            $this->Permissions->add([
                'group_id' => $group->id,
                'name' => 'StaffGroups.permissions.admin_clients_' . $action,
                'alias' => 'admin_clients',
                'action' => $action
            ]);

            foreach ($staff_groups as $staff_group) {
                // Grant access to the new permission if the group can already view notes
                if (!empty($staff_group_access[$staff_group->id])) {
                    $this->Acl->allow('staff_group_' . $staff_group->id, 'admin_clients', $action);
                } else {
                    $this->Acl->deny('staff_group_' . $staff_group->id, 'admin_clients', $action);
                }
            }
        }
    }

    /**
     * Converts stored user avatar paths from absolute filesystem paths to paths
     * relative to the uploads directory (e.g. "1/avatars/file.jpg") so avatars
     * survive a change to the uploads_dir setting. Rows whose path does not match
     * any company's uploads directory are left absolute and continue to work via
     * the legacy fallback in Users::getAvatarPath().
     *
     * @param bool $undo Whether to undo the upgrade
     */
    private function updateUserAvatarPaths($undo = false)
    {
        // Map each company to its effective uploads directory
        $system_setting = $this->Record->select(['value'])
            ->from('settings')
            ->where('key', '=', 'uploads_dir')
            ->fetch();

        $companies = $this->Record->select(['id'])->from('companies')->fetchAll();
        $uploads_dirs = [];
        foreach ($companies as $company) {
            $company_setting = $this->Record->select(['value'])
                ->from('company_settings')
                ->where('key', '=', 'uploads_dir')
                ->where('company_id', '=', $company->id)
                ->fetch();

            $uploads_dirs[$company->id] = $company_setting->value ?? ($system_setting->value ?? '');
        }

        $avatars = $this->Record->select()->from('user_avatars')->fetchAll();
        foreach ($avatars as $avatar) {
            if ($undo) {
                // Restore the absolute path by prepending the uploads directory
                // matching the company segment of the relative path
                if (preg_match('#^(\d+)/avatars/#', $avatar->file_name, $matches)
                    && !empty($uploads_dirs[$matches[1]])
                ) {
                    $this->Record->where('user_id', '=', $avatar->user_id)->update(
                        'user_avatars',
                        ['file_name' => $uploads_dirs[$matches[1]] . str_replace('/', DS, $avatar->file_name)]
                    );
                }
            } else {
                // Strip the matching uploads directory prefix
                foreach ($uploads_dirs as $uploads_dir) {
                    if ($uploads_dir != '' && str_starts_with($avatar->file_name, $uploads_dir)) {
                        $this->Record->where('user_id', '=', $avatar->user_id)->update(
                            'user_avatars',
                            ['file_name' => str_replace(DS, '/', substr($avatar->file_name, strlen($uploads_dir)))]
                        );
                        break;
                    }
                }
            }
        }
    }
}
