<?php

declare(strict_types=1);
/**
 * Copyright 2022-2025 FOSSBilling
 * Copyright 2011-2021 BoxBilling, Inc.
 * SPDX-License-Identifier: Apache-2.0.
 *
 * @copyright FOSSBilling (https://www.fossbilling.org)
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
 */

namespace Box\Mod\Client;

use FOSSBilling\InformationException;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\Tools;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Intl\Locales;

class Service implements InjectionAwareInterface
{
    protected ?\Pimple\Container $di = null;

    public function getModulePermissions(): array
    {
        return [
            'view' => [
                'type' => 'bool',
                'display_name' => __trans('View Client Details'),
                'description' => __trans('Allows the staff member to view client account details and listings.'),
            ],
            'create' => [
                'type' => 'bool',
                'display_name' => __trans('Create Clients'),
                'description' => __trans('Allows the staff member to create new client accounts.'),
            ],
            'edit_profile' => [
                'type' => 'bool',
                'display_name' => __trans('Edit Client Profiles'),
                'description' => __trans('Allows the staff member to update client profile details and account settings.'),
            ],
            'impersonate_login' => [
                'type' => 'bool',
                'display_name' => __trans('Login as Client'),
                'description' => __trans('Allows the staff member to authenticate as any client account.'),
            ],
            'manage_api_keys' => [
                'type' => 'bool',
                'display_name' => __trans('Manage Client API Keys'),
                'description' => __trans('Allows the staff member to view and generate API keys for client accounts.'),
            ],
            'change_password' => [
                'type' => 'bool',
                'display_name' => __trans('Change Client Passwords'),
                'description' => __trans('Allows the staff member to set new passwords for client accounts.'),
            ],
            'manage_balance' => [
                'type' => 'bool',
                'display_name' => __trans('Manage Client Balance'),
                'description' => __trans('Allows the staff member to add or remove balance entries for client accounts.'),
            ],
            'view_login_history' => [
                'type' => 'bool',
                'display_name' => __trans('View Client Login History'),
                'description' => __trans('Allows the staff member to view client login history and IP addresses.'),
            ],
            'manage_groups' => [
                'type' => 'bool',
                'display_name' => __trans('Manage Client Groups'),
                'description' => __trans('Allows the staff member to create, update, and delete client groups.'),
            ],
            'delete' => [
                'type' => 'bool',
                'display_name' => __trans('Delete Clients'),
                'description' => __trans('Allows the staff member to permanently remove client accounts.'),
            ],
            'bulk_delete' => [
                'type' => 'bool',
                'display_name' => __trans('Bulk Delete Clients'),
                'description' => __trans('Allows the staff member to permanently remove multiple client accounts in a single action.'),
            ],
            'export' => [
                'type' => 'bool',
                'display_name' => __trans('Export Clients'),
                'description' => __trans('Allows the staff member to export client account data.'),
            ],
            'manage_settings' => [
                'type' => 'bool',
                'display_name' => __trans('Manage Client Settings'),
                'description' => __trans('Allows the staff member to manage client module settings and configuration.'),
            ],
        ];
    }

    public function setDi(\Pimple\Container $di): void
    {
        $this->di = $di;
    }

    public function getDi(): ?\Pimple\Container
    {
        return $this->di;
    }

    public function approveClientEmailByHash($hash): bool
    {
        $db = $this->di['db'];
        $result = $db->getRow('SELECT id, client_id FROM extension_meta WHERE extension = "mod_client" AND meta_key = "confirm_email" AND meta_value = :hash', [':hash' => $hash]);
        if (!$result) {
            throw new InformationException('Invalid email confirmation link');
        }
        $db->exec('UPDATE client SET email_approved = 1 WHERE id = :id', ['id' => $result['client_id']]);
        $db->exec('DELETE FROM extension_meta WHERE id = :id', ['id' => $result['id']]);

        return true;
    }

    public function generateEmailConfirmationLink($client_id)
    {
        $hash = strtolower((string) $this->di['tools']->generatePassword(50));
        $db = $this->di['db'];

        $meta = $db->dispense('ExtensionMeta');
        $meta->extension = 'mod_client';
        $meta->client_id = $client_id;
        $meta->meta_key = 'confirm_email';
        $meta->meta_value = $hash;
        $meta->created_at = date('Y-m-d H:i:s');
        $meta->updated_at = date('Y-m-d H:i:s');
        $db->store($meta);

        return $this->di['tools']->url('/client/confirm-email/' . $hash);
    }

    public static function onAfterClientSignUp(\Box_Event $event): bool
    {
        $di = $event->getDi();
        $params = $event->getParameters();
        $config = $di['mod_config']('client');
        $emailService = $di['mod_service']('email');

        try {
            $email = [];
            $email['to_client'] = $params['id'];
            $email['code'] = 'mod_client_signup';
            $email['require_email_confirmation'] = false;
            if (isset($config['require_email_confirmation']) && $config['require_email_confirmation']) {
                $clientService = $di['mod_service']('client');
                $email['require_email_confirmation'] = true;
                $email['email_confirmation_link'] = $clientService->generateEmailConfirmationLink($params['id']);
            }

            $emailService->sendTemplate($email);
        } catch (\Exception $exc) {
            $di['logger']->setChannel('email')->error('Failed to send client signup email', ['exception' => $exc->getMessage()]);
        }

        return true;
    }

    public function getSearchQuery($data, $selectStmt = 'SELECT c.*'): array
    {
        $sql = $selectStmt;
        $sql .= ' FROM client as c left join client_group as cg on c.client_group_id = cg.id';

        $search = (isset($data['search']) && !empty($data['search'])) ? $data['search'] : null;
        $client_id = (isset($data['client_id']) && !empty($data['client_id'])) ? $data['client_id'] : null;
        $group_id = (isset($data['group_id']) && !empty($data['group_id'])) ? $data['group_id'] : null;
        $id = (isset($data['id']) && !empty($data['id'])) ? $data['id'] : null;
        $status = (isset($data['status']) && !empty($data['status'])) ? $data['status'] : null;
        $name = (isset($data['name']) && !empty($data['name'])) ? $data['name'] : null;
        $company = (isset($data['company']) && !empty($data['company'])) ? $data['company'] : null;
        $email = (isset($data['email']) && !empty($data['email'])) ? $data['email'] : null;
        $created_at = (isset($data['created_at']) && !empty($data['created_at'])) ? $data['created_at'] : null;
        $date_from = (isset($data['date_from']) && !empty($data['date_from'])) ? $data['date_from'] : null;
        $date_to = (isset($data['date_to']) && !empty($data['date_to'])) ? $data['date_to'] : null;

        $where = [];
        $params = [];
        if ($id) {
            $where[] = '(c.id = :client_id OR c.aid = :alt_client_id)';
            $params[':client_id'] = $id;
            $params[':alt_client_id'] = $id;
        }

        if ($name) {
            $where[] = '(c.first_name LIKE :first_name or c.last_name LIKE :last_name )';
            $name = '%' . $name . '%';
            $params[':first_name'] = $name;
            $params[':last_name'] = $name;
        }

        if ($email) {
            $where[] = 'c.email LIKE :email';
            $params[':email'] = '%' . $email . '%';
        }

        if ($company) {
            $where[] = 'c.company LIKE :company';
            $params[':company'] = '%' . $company . '%';
        }

        if ($status) {
            $where[] = 'c.status = :status';
            $params[':status'] = $status;
        }

        if ($group_id) {
            $where[] = 'c.client_group_id = :group_id';
            $params[':group_id'] = $group_id;
        }

        if ($created_at) {
            $where[] = "DATE_FORMAT(c.created_at, '%Y-%m-%d') = :created_at";
            $params[':created_at'] = date('Y-m-d', strtotime((string) $created_at));
        }

        if ($date_from) {
            $where[] = 'UNIX_TIMESTAMP(c.created_at) >= :date_from';
            $params[':date_from'] = strtotime((string) $date_from);
        }

        if ($date_to) {
            $where[] = 'UNIX_TIMESTAMP(c.created_at) <= :date_to';
            $params[':date_to'] = strtotime((string) $date_to);
        }

        // smartSearch
        if ($search) {
            if (is_numeric($search)) {
                $where[] = '(c.id = :cid OR c.aid = :caid)';
                $params[':cid'] = $search;
                $params[':caid'] = $search;
            } else {
                $where[] = "(c.company LIKE :s_company OR c.first_name LIKE :s_first_name OR c.last_name LIKE :s_last_name OR c.email LIKE :s_email OR CONCAT(c.first_name,  ' ', c.last_name ) LIKE  :full_name)";
                $search = '%' . $search . '%';
                $params[':s_company'] = $search;
                $params[':s_first_name'] = $search;
                $params[':s_last_name'] = $search;
                $params[':s_email'] = $search;
                $params[':full_name'] = $search;
            }
        }

        if (!empty($where)) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY c.created_at desc';

        return [$sql, $params];
    }

    public function getPairs($data)
    {
        $limit = $data['per_page'] ?? 30;
        if (!is_numeric($limit) || $limit < 1) {
            throw new InformationException('Invalid per page number');
        }

        [$sql, $params] = $this->getSearchQuery($data, "SELECT c.id, IF(c.company <> '', CONCAT_WS(' ', c.first_name, c.last_name, ' (', c.company, ')'), CONCAT_WS(' ', c.first_name, c.last_name)) as client");
        $sql .= sprintf(' LIMIT %u', $limit);

        return $this->di['db']->getAssoc($sql, $params);
    }

    public function toSessionArray(\Model_Client $model): array
    {
        return [
            'id' => $model->id,
            'email' => $model->email,
            'name' => $model->getFullName(),
            'role' => $model->role,
        ];
    }

    public function emailAlreadyRegistered($new_email, ?\Model_Client $model = null)
    {
        if ($model instanceof \Model_Client && $model->email == $new_email) {
            return false;
        }

        $result = $this->di['db']->findOne('Client', 'email = ?', [$new_email]);

        return ($result) ? true : false;
    }

    public function canChangeCurrency(\Model_Client $model, $currency = null): bool
    {
        if (!$model->currency) {
            return true;
        }

        if ($model->currency == $currency) {
            return false;
        }

        $invoice = $this->di['db']->findOne('Invoice', 'client_id = :client_id', [':client_id' => $model->id]);
        if ($invoice instanceof \Model_Invoice) {
            throw new InformationException('Currency cannot be changed. Client already has invoices issued.');
        }

        $order = $this->di['db']->findOne('ClientOrder', 'client_id = :client_id', [':client_id' => $model->id]);
        if ($order instanceof \Model_ClientOrder) {
            throw new InformationException('Currency cannot be changed. Client already has orders.');
        }

        return true;
    }

    public function addFunds(\Model_Client $client, $amount, $description, array $data = []): bool
    {
        if (!$client->currency) {
            throw new InformationException('You must define the client\'s currency before adding funds.');
        }

        if (!is_numeric($amount)) {
            throw new InformationException('Funds amount is invalid');
        }

        if (empty($description)) {
            throw new InformationException('Funds description is invalid');
        }

        $credit = $this->di['db']->dispense('ClientBalance');

        $credit->client_id = $client->id;
        $credit->type = $data['type'] ?? 'gift';
        $credit->rel_id = $data['rel_id'] ?? null;
        $credit->description = $description;
        $credit->amount = $amount;
        $credit->created_at = date('Y-m-d H:i:s');
        $credit->updated_at = date('Y-m-d H:i:s');

        $this->di['db']->store($credit);

        return true;
    }

    public function getExpiredPasswordReminders()
    {
        $expire_after_hours = 2;

        return $this->di['db']->find('ClientPasswordReset', 'UNIX_TIMESTAMP() - ? > UNIX_TIMESTAMP(created_at)', [$expire_after_hours * 60 * 60]);
    }

    public function getHistorySearchQuery($data): array
    {
        $q = 'SELECT ach.*, c.first_name, c.last_name, c.email
              FROM activity_client_history as ach
                LEFT JOIN client as c on ach.client_id = c.id ';

        $id = $data['id'] ?? null;
        $search = $data['search'] ?? null;
        $client_id = $data['client_id'] ?? null;
        $ip = $data['ip'] ?? null;
        $date_from = $data['date_from'] ?? null;
        $date_to = $data['date_to'] ?? null;

        $where = [];
        $params = [];

        if ($id !== null && $id !== '') {
            $where[] = 'ach.id = :event_id';
            $params[':event_id'] = (int) $id;
        }

        if ($search) {
            $where[] = '(c.first_name LIKE :first_name OR c.last_name LIKE :last_name OR c.email LIKE :email OR c.id LIKE :id)';
            $params[':first_name'] = '%' . $search . '%';
            $params[':last_name'] = '%' . $search . '%';
            $params[':email'] = '%' . $search . '%';
            $params[':id'] = $search;
        }

        if ($client_id) {
            $where[] = 'ach.client_id = :client_id';
            $params[':client_id'] = $client_id;
        }

        if ($ip !== null && $ip !== '') {
            $where[] = 'ach.ip LIKE :ip';
            $params[':ip'] = '%' . $ip . '%';
        }

        if ($date_from !== null && $date_from !== '') {
            $where[] = 'ach.created_at >= :date_from';
            $params[':date_from'] = date('Y-m-d 00:00:00', strtotime((string) $date_from));
        }

        if ($date_to !== null && $date_to !== '') {
            $where[] = 'ach.created_at <= :date_to';
            $params[':date_to'] = date('Y-m-d 23:59:59', strtotime((string) $date_to));
        }

        if (!empty($where)) {
            $q .= ' WHERE ' . implode(' AND ', $where);
        }

        $q .= ' ORDER BY ach.id desc';

        return [$q, $params];
    }

    public function counter(): array
    {
        $sql = 'SELECT status, COUNT(id) as counter
                FROM client
                group by status';
        $data = $this->di['db']->getAssoc($sql);

        return [
            'total' => array_sum($data),
            \Model_Client::ACTIVE => $data[\Model_Client::ACTIVE] ?? 0,
            \Model_Client::SUSPENDED => $data[\Model_Client::SUSPENDED] ?? 0,
            \Model_Client::CANCELED => $data[\Model_Client::CANCELED] ?? 0,
        ];
    }

    public function getGroupPairs()
    {
        $sql = 'SELECT id, title
                FROM client_group';

        return $this->di['db']->getAssoc($sql);
    }

    public function clientAlreadyExists($email): bool
    {
        $client = $this->di['db']->findOne('Client', 'email = :email ', [':email' => $email]);

        return $client instanceof \Model_Client;
    }

    public function getByLoginDetails($email, $password)
    {
        return $this->di['db']->findOne('Client', 'email = ? and pass = ? and status = ?', [$email, $password, \Model_Client::ACTIVE]);
    }

    public function toApiArray(\Model_Client $model, $deep = false, $identity = null, bool $includeSensitive = false): array
    {
        $isAdmin = $identity instanceof \Model_Admin;
        $details = [
            'id' => $model->id,
            'email' => $model->email,
            'email_approved' => $model->email_approved,
            'type' => $model->type,
            'company' => $model->company,
            'company_vat' => $model->company_vat,
            'company_number' => $model->company_number,
            'first_name' => $model->first_name,
            'last_name' => $model->last_name,
            'gender' => $model->gender,
            'birthday' => $model->birthday,
            'phone_cc' => $model->phone_cc,
            'phone' => $model->phone,
            'address_1' => $model->address_1,
            'address_2' => $model->address_2,
            'city' => $model->city,
            'state' => $model->state,
            'postcode' => $model->postcode,
            'country' => $model->country,
            'currency' => $model->currency,
            'document_nr' => $model->document_nr,
        ];

        if ($deep) {
            $details['balance'] = $this->getClientBalance($model);
        }

        $m = $this->di['db']->toArray($model);
        $clientVisibleCustomFields = [];
        if (!$isAdmin) {
            $config = $this->di['mod_config']('client');
            $clientVisibleCustomFields = array_filter(
                $config['custom_fields'] ?? [],
                fn ($field): bool => isset($field['active']) && $field['active']
            );
        }

        for ($i = 1; $i < 11; ++$i) {
            $k = 'custom_' . $i;
            if (isset($m[$k]) && !empty($m[$k]) && ($isAdmin || isset($clientVisibleCustomFields[$k]))) {
                $details[$k] = $m[$k];
            }
        }

        if ($isAdmin) {
            $clientGroup = $this->di['db']->load('ClientGroup', $model->client_group_id);

            $details['aid'] = $model->aid;
            $details['auth_type'] = $model->auth_type;
            $details['created_at'] = $model->created_at;
            $details['group_id'] = $model->client_group_id;
            $details['ip'] = $model->ip;
            $details['notes'] = $model->notes;
            $details['status'] = $model->status;
            $details['tax_exempt'] = $model->tax_exempt;
            $details['group'] = ($clientGroup) ? $clientGroup->title : null;
            $details['updated_at'] = $model->updated_at;
            $details['email_approved'] = $model->email_approved;

            if ($includeSensitive) {
                $details['api_token'] = $model->api_token;
            }
        }

        return $details;
    }

    public function getClientBalance(\Model_Client $c): float
    {
        $sql = 'SELECT SUM(amount) as client_total
                FROM client_balance
                WHERE client_id = ?
                GROUP BY client_id';

        return (float) $this->di['db']->getCell($sql, [$c->id]);
    }

    public function get($data)
    {
        if (!isset($data['id']) && !isset($data['email'])) {
            throw new InformationException('Client ID or email is required');
        }

        $db = $this->di['db'];
        $client = null;
        if (isset($data['id'])) {
            $client = $db->findOne('Client', 'id = ?', [$data['id']]);
        }

        if (!$client && isset($data['email'])) {
            $client = $db->findOne('Client', 'email = ?', [$data['email']]);
        }

        if (!$client instanceof \Model_Client) {
            throw new \FOSSBilling\Exception('Client not found');
        }

        return $client;
    }

    public function isClientTaxable(\Model_Client $model): bool
    {
        $systemService = $this->di['mod_service']('system');

        if (!$systemService->getParamValue('tax_enabled', false)) {
            return false;
        }

        if ($model->tax_exempt) {
            return false;
        }

        return true;
    }

    public function createGroup(array $data)
    {
        $model = $this->di['db']->dispense('ClientGroup');

        $model->title = $data['title'];
        $model->updated_at = date('Y-m-d H:i:s');
        $model->created_at = date('Y-m-d H:i:s');

        $group_id = $this->di['db']->store($model);

        $this->di['logger']->info('Created new client group #%s', $model->id);

        return $group_id;
    }

    public function deleteGroup(\Model_ClientGroup $model): bool
    {
        $client = $this->di['db']->findOne('Client', 'client_group_id = ?', [$model->id]);
        if ($client) {
            throw new \FOSSBilling\Exception('Cannot remove groups with clients');
        }

        $this->di['db']->trash($model);
        $this->di['logger']->info('Removed client group #%s', $model->id);

        return true;
    }

    private function createClient(array $data)
    {
        $password = $data['password'] ?? $this->di['tools']->generatePassword(32, true);

        $client = $this->di['db']->dispense('Client');

        $client->auth_type = $data['auth_type'] ?? null;
        $client->email = strtolower(trim((string) ($data['email'] ?? null)));
        $client->first_name = ucwords((string) ($data['first_name'] ?? null));
        $client->pass = $this->di['password']->hashIt($password);

        $system = $this->di['mod']('system');
        $systemCfg = $system->getConfig();

        // Special handling for the phone country codes
        $phoneCC = $data['phone_cc'] ?? null;
        if (!empty($phoneCC)) {
            $client->phone_cc = Tools::validatePhoneCC($phoneCC);
        }

        // Special handling for the phone number itself
        $phone = $data['phone'] ?? null;
        if (!empty($phone) && is_string($phone)) {
            $client->phone = Tools::validatePhoneNumber($phone);
        }

        $client->aid = $data['aid'] ?? null;
        $client->last_name = $data['last_name'] ?? null;
        $client->client_group_id = !empty($data['group_id']) ? $data['group_id'] : null;
        $client->status = $data['status'] ?? null;
        $client->gender = $data['gender'] ?? null;
        $client->birthday = $data['birthday'] ?? null;
        $client->company = $data['company'] ?? null;
        $client->company_vat = $data['company_vat'] ?? null;
        $client->company_number = $data['company_number'] ?? null;
        $client->type = $data['type'] ?? null;
        $client->address_1 = $data['address_1'] ?? null;
        $client->address_2 = $data['address_2'] ?? null;
        $client->city = $data['city'] ?? null;
        $client->state = $data['state'] ?? null;
        $client->postcode = $data['postcode'] ?? null;
        $client->country = !empty($data['country']) ? $data['country'] : (!empty($systemCfg['default_country']) ? $systemCfg['default_country'] : null);
        if ($client->country !== null && !Countries::exists($client->country)) {
            throw new InformationException('Invalid country code: :code', [':code' => $client->country]);
        }
        $client->document_type = $data['document_type'] ?? null;
        $client->document_nr = $data['document_nr'] ?? null;
        $client->notes = $data['notes'] ?? null;
        $client->lang = $data['lang'] ?? null;
        if ($client->lang !== null && $client->lang !== '' && !Locales::exists($client->lang)) {
            throw new InformationException('Invalid locale code: :code', [':code' => $client->lang]);
        }
        $client->currency = $data['currency'] ?? null;

        $client->custom_1 = $data['custom_1'] ?? null;
        $client->custom_2 = $data['custom_2'] ?? null;
        $client->custom_3 = $data['custom_3'] ?? null;
        $client->custom_4 = $data['custom_4'] ?? null;
        $client->custom_5 = $data['custom_5'] ?? null;
        $client->custom_6 = $data['custom_6'] ?? null;
        $client->custom_7 = $data['custom_7'] ?? null;
        $client->custom_8 = $data['custom_8'] ?? null;
        $client->custom_9 = $data['custom_9'] ?? null;
        $client->custom_10 = $data['custom_10'] ?? null;

        $client->ip = $data['ip'] ?? null;

        $created_at = $data['created_at'] ?? null;
        $client->created_at = !empty($created_at) ? date('Y-m-d H:i:s', strtotime((string) $created_at)) : date('Y-m-d H:i:s');
        $client->updated_at = date('Y-m-d H:i:s');
        $this->di['db']->store($client);

        return $client;
    }

    public function adminCreateClient(array $data)
    {
        $eventParams = $data;
        unset($eventParams['password'], $eventParams['password_confirm']);
        $this->di['events_manager']->fire(['event' => 'onBeforeAdminCreateClient', 'params' => $eventParams]);
        $client = $this->createClient($data);
        if (Tools::normalizeBoolean($data['send_welcome_email'] ?? true, true)) {
            $this->sendAdminCreatedWelcomeEmailForClient($client);
        }
        $this->di['events_manager']->fire(['event' => 'onAfterAdminCreateClient', 'params' => ['id' => $client->id]]);
        $this->di['logger']->info('Created new client #%s', $client->id);

        return $client->id;
    }

    public function guestCreateClient(array $data)
    {
        $event_params = $data;
        $event_params['ip'] = $this->di['request']->getClientIp();
        unset($event_params['password'], $event_params['password_confirm']);
        $this->di['events_manager']->fire(['event' => 'onBeforeClientSignUp', 'params' => $event_params]);

        $allowedFields = [
            'email', 'first_name', 'last_name', 'password',
            'phone', 'phone_cc', 'gender', 'birthday',
            'company', 'company_vat', 'company_number', 'type',
            'address_1', 'address_2', 'city', 'state', 'postcode', 'country',
            'document_type', 'document_nr',
            'custom_1', 'custom_2', 'custom_3', 'custom_4', 'custom_5',
            'custom_6', 'custom_7', 'custom_8', 'custom_9', 'custom_10',
        ];

        $safeData = [
            'ip' => $this->di['request']->getClientIp(),
            'status' => \Model_Client::ACTIVE,
        ];
        foreach ($allowedFields as $field) {
            if (array_key_exists($field, $data)) {
                $safeData[$field] = $data[$field];
            }
        }

        $client = $this->createClient($safeData);

        $event_params = [
            'id' => $client->id,
            'email' => $client->email,
            'first_name' => $client->first_name,
            'last_name' => $client->last_name,
            'ip' => $safeData['ip'],
        ];
        $this->di['events_manager']->fire(['event' => 'onAfterClientSignUp', 'params' => $event_params]);
        $this->di['logger']->info('Client #%s signed up', $client->id);

        return $client;
    }

    public function createPasswordResetRequestForClient(\Model_Client $client): string
    {
        $existingReset = $this->di['db']->findOne('ClientPasswordReset', 'client_id = ?', [$client->id]);
        if ($existingReset instanceof \Model_ClientPasswordReset) {
            $this->di['db']->trash($existingReset);
        }

        $requestIp = null;
        if (isset($this->di['request']) && is_object($this->di['request']) && method_exists($this->di['request'], 'getClientIp')) {
            $requestIp = $this->di['request']->getClientIp();
        }

        $hash = hash('sha256', random_bytes(32));
        $reset = $this->di['db']->dispense('ClientPasswordReset');
        $reset->client_id = $client->id;
        $reset->ip = $requestIp ?? $client->ip;
        $reset->hash = $hash;
        $reset->created_at = date('Y-m-d H:i:s');
        $reset->updated_at = date('Y-m-d H:i:s');
        $this->di['db']->store($reset);

        return $hash;
    }

    public function sendPasswordResetRequestEmailForClient(\Model_Client $client, string $hash, bool $sendNow = true): void
    {
        $email = [
            'to_client' => $client->id,
            'code' => 'mod_client_password_reset_request',
            'hash' => $hash,
            'send_now' => $sendNow,
        ];

        $emailService = $this->di['mod_service']('email');
        $emailService->sendTemplate($email);
    }

    public function sendAdminCreatedWelcomeEmailForClient(\Model_Client $client): void
    {
        try {
            $email = [];
            $email['to_client'] = $client->id;
            $email['code'] = 'mod_client_signup_admin';
            $email['hash'] = $this->createPasswordResetRequestForClient($client);
            $email['send_now'] = true;
            $email['require_email_confirmation'] = false;

            $config = $this->di['mod_config']('client');
            if (isset($config['require_email_confirmation']) && $config['require_email_confirmation']) {
                $email['require_email_confirmation'] = true;
                $email['email_confirmation_link'] = $this->generateEmailConfirmationLink($client->id);
            }

            $emailService = $this->di['mod_service']('email');
            $emailService->sendTemplate($email);
        } catch (\Exception $exc) {
            $this->di['logger']->setChannel('email')->error('Failed to send client welcome email', ['exception' => $exc->getMessage()]);
        }
    }

    public function remove(\Model_Client $model): void
    {
        $service = $this->di['mod_service']('Order');
        $service->rmByClient($model);
        $service = $this->di['mod_service']('Invoice');
        $service->rmByClient($model);
        $service = $this->di['mod_service']('Support');
        $service->rmByClient($model);
        $service = $this->di['mod_service']('Client', 'Balance');
        $service->rmByClient($model);

        $table = $this->di['table']('ActivityClientHistory');
        $table->rmByClient($model);

        $service = $this->di['mod_service']('Email');
        $service->rmByClient($model);
        $service = $this->di['mod_service']('Activity');
        $service->rmByClient($model);

        $resetRecords = $this->di['db']->find('ClientPasswordReset', 'client_id = ?', [$model->id]);
        foreach ($resetRecords as $resetRecord) {
            $this->di['db']->trash($resetRecord);
        }

        $query = $this->di['dbal']->createQueryBuilder();
        $query
            ->delete('extension_meta')
            ->where('client_id = :id')
            ->setParameter('id', $model->id);
        $query->executeStatement();

        $this->di['db']->trash($model);
    }

    public function authorizeClient($email, $plainTextPassword)
    {
        $model = $this->di['db']->findOne('Client', 'email = ? AND status = ?', [$email, \Model_Client::ACTIVE]);

        return $this->di['auth']->authorizeUser($model, $plainTextPassword);
    }

    public function sendEmailConfirmationForClient(\Model_Client $client): void
    {
        try {
            $email = [];
            $email['to_client'] = $client->id;
            $email['code'] = 'mod_client_confirm';
            $email['require_email_confirmation'] = true;
            $email['email_confirmation_link'] = $this->generateEmailConfirmationLink($client->id);
            $email['send_now'] = true;

            $emailService = $this->di['mod_service']('email');
            $emailService->sendTemplate($email);
        } catch (\Exception $exc) {
            $this->di['logger']->setChannel('email')->error('Failed to send email confirmation email', ['exception' => $exc->getMessage()]);
        }
    }

    public function canChangeEmail(\Model_Client $client, $email): bool
    {
        $config = $this->di['mod_config']('client');

        if (
            $client->email != $email
            && isset($config['disable_change_email'])
            && $config['disable_change_email']
        ) {
            throw new InformationException('Email address cannot be changed');
        }

        return true;
    }

    public function checkExtraRequiredFields(array $checkArr): void
    {
        $config = $this->di['mod_config']('client');
        $required = $config['required'] ?? [];
        foreach ($required as $field) {
            if (!isset($checkArr[$field]) || empty($checkArr[$field])) {
                $name = ucwords(str_replace('_', ' ', $field));

                throw new InformationException('Field :field cannot be empty', [':field' => $name]);
            }
        }
    }

    public function checkCustomFields(array $checkArr): void
    {
        $config = $this->di['mod_config']('client');
        $customFields = $config['custom_fields'] ?? [];
        foreach ($customFields as $cFieldName => $cField) {
            $active = isset($cField['active']) && $cField['active'];
            $required = isset($cField['required']) && $cField['required'];
            if ($active && $required) {
                if (!isset($checkArr[$cFieldName]) || empty($checkArr[$cFieldName])) {
                    $name = isset($cField['title']) && !empty($cField['title']) ? $cField['title'] : ucwords(str_replace('_', ' ', $cFieldName));

                    throw new InformationException('Field :field cannot be empty', [':field' => $name]);
                }
            }
        }
    }

    public function exportCSV(array $headers): Response
    {
        if ($headers) {
            // Prevent the password / salt columns from being exported
            if (isset($headers['pass'])) {
                unset($headers['pass']);
            }
            if (isset($headers['salt'])) {
                unset($headers['salt']);
            }
        } else {
            $headers = ['id', 'email', 'status', 'first_name', 'last_name', 'phone_cc', 'phone', 'company', 'company_vat', 'company_number', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'currency'];
        }

        return $this->di['csv_response_factory']->create('client', 'clients.csv', $headers);
    }

    /**
     * Confirm password reset action.
     *
     * @return bool|int
     *
     * @throws InformationException
     */
    public function password_reset_valid($data)
    {
        $required = [
            'hash' => 'Hash required',
        ];
        $this->di['events_manager']->fire(['event' => 'onBeforePasswordResetClient']);
        $this->di['validator']->checkRequiredParamsForArray($required, $data);

        $reset = $this->di['db']->findOne('ClientPasswordReset', 'hash = ?', [$data['hash']]);
        if (!$reset instanceof \Model_ClientPasswordReset) {
            throw new InformationException('The link has expired or you have already reset your password.');
        }

        $c = $this->di['db']->findOne('Client', 'id = ?', [$reset->client_id]);
        // Return the client ID if the reset request is valid (from within the last 15 minutes), otherwise return false
        if (strtotime($reset->created_at) - time() + 900 < 0) {
            return false;
        }

        return $c->id;
    }

    /*
     * Prunes the `client_password_reset` table of reset requests older than 15 minutes
     *
     * @return void
     */
    public static function onBeforeAdminCronRun(\Box_Event $event): void
    {
        $di = $event->getDi();
        $sql = 'DELETE FROM client_password_reset WHERE UNIX_TIMESTAMP() - 900 > UNIX_TIMESTAMP(created_at);';

        try {
            $db = $di['db'];
            $db->exec($sql);
        } catch (\Exception $e) {
            if (!\FOSSBilling\Environment::isTesting()) {
                error_log($e->getMessage());
            }
        }
    }
}
