<?php

namespace Blesta\App\Models;

use Blesta\App\AppModel;
use Configure;
use Exception;
use Language;
use Loader;
use PDOException;
use Record;
use stdClass;
use Throwable;

/**
 * Quotations management
 *
 * @package blesta
 * @subpackage app.models
 * @copyright Copyright (c) 2022, Phillips Data, Inc.
 * @license http://www.blesta.com/license/ The Blesta License Agreement
 * @link http://www.blesta.com/ Blesta
 */
class Quotations extends AppModel
{
    /**
     * Initialize Invoices
     */
    public function __construct()
    {
        parent::__construct();
        Language::loadLang(['quotations']);
    }

    /**
     * Creates a new quotation using the given data
     *
     * @param array $vars An array of quotation data including:
     *
     *  - client_id The client ID the quote belongs to
     *  - staff_id The staff ID the quote was generated by
     *  - title The title of the quotation
     *  - status The status of the quotation, it could be 'draft', 'pending', 'approved',
     *      'invoiced', 'expired', 'dead' or 'lost'
     *  - currency The ISO 4217 3-character currency code of the quote
     *  - notes Notes visible to the client
     *  - private_notes Notes visible to the staff
     *  - date_created The date the quotation was created
     *  - date_expires The date the quotation expires
     *  - lines A numerically indexed array of line item info including:
     *      - description The line item description
     *      - qty The quantity for this line item (min. 1)
     *      - amount The unit cost (cost per quantity) for this line item
     *      - tax Whether or not to tax the line item
     * @return int The quote ID, void on error
     */
    public function add(array $vars)
    {
        // Trigger the Quotations.addBefore event
        $event = $this->executeAndParseEvent('Quotations.addBefore', ['vars' => $vars]);
        if ($event instanceof \Blesta\Core\Util\Events\Common\EventInterface && ($errors = $event->getErrors())) {
            $this->Input->setErrors($errors);
            return;
        }
        extract($event);

        // Fetch client settings on quotes
        Loader::loadComponents($this, ['SettingsCollection']);
        $client_settings = $this->SettingsCollection->fetchClientSettings($vars['client_id']);

        $vars = $this->getNextQuotationVars($vars, $client_settings);

        // Note: there must be at least 1 line item
        $this->Input->setRules($this->getRules($vars));

        $tries = Configure::get('Blesta.transaction_deadlock_reattempts');
        do {
            $retry = false;

            try {
                return $this->makeQuote($vars, $client_settings);
            } catch (PDOException $e) {
                // A deadlock occured (PDO error 1213, SQLState 40001)
                if ($tries > 0 && $e->getCode() == '40001' && str_contains($e->getMessage(), '1213')) {
                    $retry = true;
                }

                if ($this->Record->getConnection()->inTransaction()) {
                    $this->Record->rollBack();
                }
                $this->Record->reset();
            }

            $tries--;
        } while ($retry);

        // If we got this far, the system could not create the quote after several attempts
        $this->Input->setErrors(['quotation_add' => ['failed' => $this->_('Quotations.!error.quotation_add.failed')]]);
    }

    /**
     * Performs a validation check on the set input rules and attempts to create a quote
     *
     * @param array $vars An array of quote data including:
     *
     *  - client_id The client ID the quote belongs to
     *  - staff_id The staff ID the quote was generated by
     *  - title The title of the quotation
     *  - status The status of the quotation, it could be 'draft', 'pending', 'approved',
     *      'invoiced', 'expired', 'dead' or 'lost'
     *  - currency The ISO 4217 3-character currency code of the quote
     *  - notes Notes visible to the client
     *  - date_created The date the quotation was created
     *  - date_expires The date the quotation expires
     *  - lines A numerically indexed array of line item info including:
     *      - description The line item description
     *      - qty The quantity for this line item (min. 1)
     *      - amount The unit cost (cost per quantity) for this line item
     *      - tax Whether or not to tax the line item
     * @return int The quotation ID, void on error
     */
    private function makeQuote(array $vars, array $client_settings)
    {
        if (!isset($this->Clients)) {
            Loader::loadModels($this, ['Clients']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        // Copy record so that it is not overwritten during validation
        $record = clone $this->Record;
        $this->Record->reset();

        // Start the transaction
        $this->Record->begin();

        if ($this->Input->validates($vars)) {
            // Set the record back
            $this->Record = $record;
            unset($record);

            // Assign subquery values to this record component
            $this->Record->appendValues($vars['id_value']->values);

            // Ensure the subquery value is set first because its the first value
            $vars = array_merge(['id_value' => null], $vars);

            // Add quote
            $fields = [
                'id_format', 'id_value', 'client_id', 'staff_id', 'title',
                'status', 'currency', 'notes', 'private_notes', 'date_created', 'date_expires'
            ];
            $this->Record->insert('quotations', $vars, $fields);
            $quotation_id = $this->Record->lastInsertId();

            // Get tax rules for this client
            $tax_rules = $this->Invoices->getTaxRules($vars['client_id']);
            $num_taxes = count($tax_rules);

            // Add quotation line items
            $fields = ['quotation_id', 'description', 'qty', 'amount', 'order'];
            foreach ($vars['lines'] as $i => $line) {
                $line['quotation_id'] = $quotation_id;
                $line['order'] = $i;

                // Add quotation line item
                $this->Record->insert('quotation_lines', $line, $fields);
                $line_id = $this->Record->lastInsertId();

                // Add line item taxes, if set to taxable IFF tax is enabled
                if (
                    $client_settings['enable_tax'] == 'true'
                    && isset($line['tax']) && $line['tax']
                ) {
                    for ($j = 0; $j < $num_taxes; $j++) {
                        // Skip all but inclusive_calculated for tax exempt users
                        if (
                            ($client_settings['tax_exempt'] ?? 'false') == 'true'
                            && ($tax_rules[$j]->type != 'inclusive_calculated')
                        ) {
                            continue;
                        }

                        $this->addLineTax(
                            $line_id,
                            $tax_rules[$j]->id,
                            $client_settings['cascade_tax'] == 'true',
                            ($client_settings['tax_exempt'] ?? 'false') == 'true'
                            && $tax_rules[$j]->type == 'inclusive_calculated'
                        );
                    }
                }
            }

            // Commit if no errors when adding
            if (!$this->Input->errors()) {
                // Update totals
                $this->updateTotals($quotation_id);

                $this->Record->commit();

                // Log that the quotation was created
                $log = $vars;
                unset($log['id_value']);
                $this->logger->info('Created Quotation', array_merge($log, ['id' => $quotation_id]));

                // Trigger the Quotations.addAfter event
                $this->executeAndParseEvent('Quotations.addAfter', compact('quotation_id'));

                return $quotation_id;
            }
        }

        // Rollback, something went wrong
        if ($this->Record->getConnection()->inTransaction()) {
            $this->Record->rollBack();
        }
    }

    /**
     * Updates a quotation using the given data. If a new line item is added, or
     * the quantity, unit cost, or tax status of an item is updated the
     * latest tax rules will be applied to this quotation.
     *
     * @param int $quotation_id The ID of the quotation to update
     * @param array $vars An array of quotation data (all optional unless noted otherwise) including:
     *
     *  - client_id The client ID the quote belongs to
     *  - staff_id The staff ID the quote was generated by
     *  - title The title of the quotation
     *  - status The status of the quotation, it could be 'draft', 'pending', 'approved',
     *      'invoiced', 'expired', 'dead' or 'lost'
     *  - currency The ISO 4217 3-character currency code of the quote
     *  - notes Notes visible to the client
     *  - private_notes Notes visible to the staff
     *  - date_created The date the quotation was created
     *  - date_expires The date the quotation expires
     *  - lines A numerically indexed array of line item info including:
     *      - description The line item description
     *      - qty The quantity for this line item (min. 1)
     *      - amount The unit cost (cost per quantity) for this line item
     *      - tax Whether or not to tax the line item
     * @return int The quotation ID, void on error
     */
    public function edit($quotation_id, array $vars)
    {
        // Trigger the Quotations.editBefore event
        $event = $this->executeAndParseEvent('Quotations.editBefore', ['quotation_id' => $quotation_id, 'vars' => $vars]);
        if ($event instanceof \Blesta\Core\Util\Events\Common\EventInterface && ($errors = $event->getErrors())) {
            $this->Input->setErrors($errors);
            return;
        }
        extract($event);

        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Clients']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->SettingsCollection)) {
            Loader::loadComponents($this, ['SettingsCollection']);
        }

        // Get this current quotation
        $quotation = $this->get($quotation_id);

        // Fetch client settings on quotations
        $client_settings = $this->SettingsCollection->fetchClientSettings($quotation->client_id);

        // Set default values
        if (!isset($vars['client_id'])) {
            $vars['client_id'] = $quotation->client_id;
        }

        if (!isset($vars['currency'])) {
            $vars['currency'] = $quotation->currency;
        }

        $vars['prev_status'] = $quotation->status;

        $vars = $this->getNextQuotationVars($vars, $client_settings);

        // Copy record so that it is not overwritten during validation
        $record = clone $this->Record;
        $this->Record->reset();

        // Pull out line items that should be deleted
        $delete_items = [];
        // Check we have a numerically indexed line item array
        if (isset($vars['lines']) && (array_values($vars['lines']) === $vars['lines'])) {
            foreach ($vars['lines'] as $i => &$line) {
                if (!empty($line['id'])) {
                    $amount = trim($line['amount'] ?? '');
                    $description = trim($line['description'] ?? '');

                    // Set this item to be deleted, and remove it from validation check
                    // if amount and description are both empty
                    if (empty($description) && empty($amount)) {
                        $delete_items[] = $line;
                        unset($vars['lines'][$i]);
                    }
                }
            }
            unset($line);

            // Re-index array
            if (!empty($delete_items)) {
                $vars['lines'] = array_values($vars['lines']);
            }
        }

        $vars['id'] = $quotation_id;

        $rules = $this->getRules($vars);
        $line_rules = [
            'lines[][id]' => [
                'exists' => [
                    'if_set' => true,
                    'rule' => [[$this, 'validateExists'], 'id', 'quotation_lines', false],
                    'message' => $this->_('Quotations.!error.lines[][id].exists')
                ]
            ]
        ];

        // Quotation lines, currency, and status cannot be edited if the quotation has been invoiced
        if ($vars['status'] != $quotation->status) {
            $line_rules['id'] = [
                'quotation_invoiced' => [
                    'if_set' => true,
                    'rule' => function ($id) use ($quotation) {
                        return $quotation->status == 'invoiced';
                    },
                    'negate' => true,
                    'message' => $this->_('Quotations.!error.id.quotation_invoiced')
                ]
            ];
        }

        // No lines set, no descriptions required
        if (!isset($vars['lines'])) {
            $vars['lines'] = [];
            $line_rules['lines[][description]']['empty']['if_set'] = true;
        }

        // Set other rules to optional
        $rules['title']['length']['if_set'] = true;
        $rules['date_created']['format']['if_set'] = true;
        $rules['date_expires']['after_created']['if_set'] = true;

        $rules = array_merge($rules, $line_rules);

        // If the quotation wasn't already a draft, or we're not moving from a draft
        // then we can't update the id_format or id_value
        $update_statuses = ['draft'];
        if (
            !in_array($quotation->status, $update_statuses)
            || ($quotation->status == $vars['status'])
            || $vars['status'] == 'void'
        ) {
            // Do not evaluate rules for id_format and id_value because they can not be changed
            unset($rules['id_format']);
            unset($rules['id_value']);
        }

        $this->Input->setRules($rules);

        // Edit the quotation
        if ($this->Input->validates($vars)) {
            if (isset($rules['id_value'])) {
                // Set the record back
                $this->Record = $record;
                unset($record);

                // Assign subquery values to this record component
                $this->Record->appendValues($vars['id_value']->values);

                // Ensure the subquery value is set first because its the first value
                $vars = array_merge(['id_value' => null], $vars);
            }

            // Update quotation
            $fields = [
                'client_id', 'staff_id', 'date_created', 'date_expires',
                'title', 'status', 'currency', 'notes', 'private_notes'
            ];
            if (isset($rules['id_format'])) {
                $fields[] = 'id_format';
            }
            if (isset($rules['id_value'])) {
                $fields[] = 'id_value';
            }

            $this->Record->where('id', '=', $quotation_id)->update('quotations', $vars, $fields);

            if (!empty($vars['lines'])) {
                // Get the tax rules
                $tax_rules = $this->Invoices->getTaxRules($quotation->client_id);

                // Flag whether or not the quotation has been updated in such a way to
                // warrant updating the tax rules applied to the quotation
                $tax_change = $this->taxUpdateRequired($quotation_id, $vars['lines'], $delete_items);

                // Delete any line items set to be deleted
                for ($i = 0, $num_items = count($delete_items); $i < $num_items; $i++) {
                    $this->deleteLine($delete_items[$i]['id']);
                }

                // Insert and update line items and taxes
                foreach ($vars['lines'] as $i => $line) {
                    $line['quotation_id'] = $quotation_id;

                    // Add or update a line item
                    if (isset($line['id']) && !empty($line['id'])) {
                        $line_item_id = $line['id'];
                        $line['order'] = $i;

                        // Update a line item
                        $fields = ['description', 'qty', 'amount', 'order'];
                        $this->Record->where('id', '=', $line_item_id)->update('quotation_lines', $line, $fields);

                        if ($tax_change) {
                            // Delete the current line item tax rule
                            $this->deleteLineTax($line_item_id);
                        }
                    } else {
                        // Create a new line item
                        $line_item_id = $this->addLine($quotation_id, $line);
                    }

                    if ($tax_change) {
                        // Add line item taxes, if set to taxable IFF tax is enabled
                        if ($client_settings['enable_tax'] == 'true' && isset($line['tax']) && $line['tax']) {
                            for ($j = 0, $num_taxes = count($tax_rules); $j < $num_taxes; $j++) {
                                $this->addLineTax(
                                    $line_item_id,
                                    $tax_rules[$j]->id,
                                    $client_settings['cascade_tax'] == 'true',
                                    $client_settings['tax_exempt'] == 'true'
                                    && $tax_rules[$j]->type == 'inclusive_calculated'
                                );
                            }
                        }
                    }
                }
            }

            // Update totals
            $this->updateTotals($quotation_id);

            // Log that the quotation was updated
            unset($vars['id_value']);
            $this->logger->info('Updated Quotation', array_merge($vars, ['id' => $quotation_id]));

            // Trigger the Quotations.editAfter event
            $this->executeAndParseEvent(
                'Quotations.editAfter',
                ['quotation_id' => $quotation_id, 'old_quotation' => $quotation]
            );

            return $quotation_id;
        }
    }

    /**
     * Updates the status of a given quotation
     *
     * @param int $quotation_id The ID of the quotation to update
     * @param string $status The new status of the quotation
     */
    public function updateStatus($quotation_id, $status)
    {
        if (!isset($this->Session)) {
            Loader::loadComponents($this, ['Session']);
        }

        if (is_null($status)) {
            return false;
        }

        // Send approved notification
        if ($status == 'approved' && $this->Session->read('blesta_client_id') > 0) {
            // Send email notification
            $this->sendApprovedEmail($quotation_id);

            // Send messenger notification
            $this->sendApprovedNotification($quotation_id);
        }

        return $this->Record->where('id', '=', $quotation_id)->update('quotations', ['status' => $status]);
    }

    /**
     * Sends the Quotation Approved email
     *
     * @param int $quotation_id The ID of the quotation to send the approved notification
     * @return bool True if the email was sent successfully, false otherwise
     */
    private function sendApprovedEmail($quotation_id)
    {
        if (!isset($this->Clients)) {
            Loader::loadModels($this, ['Clients']);
        }

        if (!isset($this->Staff)) {
            Loader::loadModels($this, ['Staff']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->Emails)) {
            Loader::loadModels($this, ['Emails']);
        }

        if (!isset($this->Html)) {
            Loader::loadHelpers($this, ['Html']);
        }

        // Build tags
        $quotation = $this->get($quotation_id);
        $client = $this->Clients->get($quotation->client_id);
        $company = $this->Companies->get($client->company_id);
        $hostname = $company->hostname ?? '';
        $tags = [
            'quotations' => [$quotation],
            'contact' => $client,
            'company' => $company,
            'client_url' => $hostname . WEBDIR . Configure::get('Route.client') . '/'
        ];

        // Send email staff notification
        $staff = $this->Staff->get($quotation->staff_id);
        $language = $this->Staff->getSetting($staff->id, 'language');
        $tags['staff'] = $staff;
        $admin_sent = $this->Emails->send(
            'staff_quotation_approved',
            $client->company_id,
            $language ? $language->value : Configure::get('Blesta.language'),
            $staff->email,
            $tags,
            null,
            null,
            null,
            [
                'to_client_id' => null,
                'from_staff_id' => null
            ]
        );

        if (!$admin_sent) {
            return false;
        }

        return true;
    }

    /**
     * Sends the Quotation Approved notification
     *
     * @param int $quotation_id The ID of the quotation to send the approved notification
     * @return bool True if the email was sent successfully, false otherwise
     */
    private function sendApprovedNotification($quotation_id)
    {
        if (!isset($this->Clients)) {
            Loader::loadModels($this, ['Clients']);
        }

        if (!isset($this->Staff)) {
            Loader::loadModels($this, ['Staff']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->MessengerManager)) {
            Loader::loadModels($this, ['MessengerManager']);
        }

        if (!isset($this->Html)) {
            Loader::loadHelpers($this, ['Html']);
        }

        // Build tags
        $quotation = $this->get($quotation_id);
        $client = $this->Clients->get($quotation->client_id);
        $company = $this->Companies->get($client->company_id);
        $hostname = $company->hostname ?? '';
        $tags = [
            'quotations' => [$quotation],
            'contact' => $client,
            'company' => $company,
            'client_url' => $hostname . WEBDIR . Configure::get('Route.client') . '/'
        ];

        // Send email staff notification
        $staff = $this->Staff->get($quotation->staff_id);
        $tags['staff'] = $staff;
        $this->MessengerManager->send(
            'staff_quotation_approved',
            $tags,
            [$staff->user_id]
        );

        if (($errors = $this->MessengerManager->errors())) {
            return false;
        }

        return true;
    }

    /**
     * Calculates and updates the stored subtotal, total, and amount paid values for the given quotation
     *
     * @param int $quotation_id The ID of the quotation to update totals for
     */
    private function updateTotals($quotation_id)
    {
        // Ensure we have a valid quotation
        $quotation = $this->getQuotation($quotation_id)->fetch();

        if (!$quotation) {
            return;
        }

        // Fetch current totals
        $totals = $this->getPresenter($quotation_id)->totals();
        $subtotal = $totals->subtotal;
        $total = $totals->total;

        // Update totals by storing amounts to the currency's decimal precision
        $precision = $this->getCurrencyPrecision($quotation->currency, Configure::get('Blesta.company_id'));
        $data = [
            'subtotal' => round($subtotal, $precision),
            'total' => round($total, $precision)
        ];
        $this->Record->where('id', '=', $quotation_id)->update('quotations', $data);
    }

    /**
     * Retrieves the decimal precision for the given currency
     *
     * @param string $currency The ISO 4217 3-character currency code
     * @param int $company_id The ID of the company
     * @return int The currency decimal precision
     */
    private function getCurrencyPrecision($currency, $company_id)
    {
        // Determine the currency precision to use; default to 4, the maximum precision
        if (!isset($this->Currencies)) {
            Loader::loadModels($this, ['Currencies']);
        }

        $currency = $this->Currencies->get($currency, $company_id);

        return ($currency ? $currency->precision : 4);
    }

    /**
     * Fetches the given quotation
     *
     * @param int $quotation_id The ID of the quotation to fetch
     * @return mixed A stdClass object containing quotation information, false if no such quotation exists
     */
    public function get($quotation_id)
    {
        if (!isset($this->Transactions)) {
            Loader::loadModels($this, ['Transactions']);
        }

        if (!isset($this->Clients)) {
            Loader::loadModels($this, ['Clients']);
        }

        $this->Record = $this->getQuotation($quotation_id);
        $quotation = $this->Record->fetch();

        if ($quotation) {
            $quotation->line_items = $this->getLineItems($quotation_id);
            $quotation->taxes = $this->getTaxes($quotation_id);
            $quotation->invoices = $this->getInvoices($quotation_id);
        }

        return $quotation;
    }

    /**
     * Partially constructs the query required by Quotations::get() and others
     *
     * @param int $quotation_id The ID of the quotation to fetch
     * @return Record The partially constructed query Record object
     */
    private function getQuotation($quotation_id)
    {
        $fields = [
            'quotations.*',
            'REPLACE(quotations.id_format, ?, quotations.id_value)' => 'id_code'
        ];

        $this->Record->select($fields)
            ->appendValues([$this->replacement_keys['quotations']['ID_VALUE_TAG'] ?? '{num}'])
            ->from('quotations')
            ->where('quotations.id', '=', $quotation_id);

        return $this->Record;
    }

    /**
     * Fetches all line items belonging to the given quotation
     *
     * @param int $quotation_id The ID of the quotation to fetch line items for
     * @return array An array of stdClass objects each representing a line item
     */
    public function getLineItems($quotation_id)
    {
        return $this->getLines($quotation_id);
    }

    /**
     * Retrieves the line items for the given quotation
     *
     * @param int $quotation_id The quotation ID of the line items to retrieve
     * @return array An array of line items for the given invoice
     */
    private function getLines($quotation_id)
    {
        $fields = [
            'quotation_lines.id', 'quotation_lines.quotation_id', 'quotation_lines.description',
            'quotation_lines.qty', 'quotation_lines.amount', 'quotation_lines.qty*quotation_lines.amount' => 'subtotal',
            'currencies.precision'
        ];

        // Fetch all line items belonging to the given invoice
        $lines = $this->Record->select($fields)
            ->from('quotation_lines')
            ->where('quotation_lines.quotation_id', '=', $quotation_id)
            ->innerJoin('quotations', 'quotations.id', '=', 'quotation_lines.quotation_id', false)
            ->on('currencies.company_id', '=', Configure::get('Blesta.company_id'))
            ->innerJoin('currencies', 'currencies.code', '=', 'quotations.currency', false)
            ->order(['order' => 'ASC'])
            ->fetchAll();

        // Fetch tax rules for each line item
        foreach ($lines as &$line) {
            if (substr((string)$line->amount, -2) !== '00') {
                $line->precision = 4;
            }

            $line->taxes = $this->getLineTaxes($line->id);
        }

        return $lines;
    }

    /**
     * Fetches all tax info attached to the line item
     *
     * @param int $quotation_line_id The ID of the quotation line item to fetch tax info for
     * @return array An array of stdClass objects each representing a tax rule
     * @see Taxes::getAll()
     */
    private function getLineTaxes($quotation_line_id)
    {
        $fields = ['taxes.*', 'quotation_line_taxes.cascade', 'quotation_line_taxes.subtract'];

        return $this->Record->select($fields)
            ->select(['TRIM(taxes.amount)+?' => 'amount'], false)
            ->appendValues([0])
            ->from('quotation_line_taxes')
            ->innerJoin('taxes', 'quotation_line_taxes.tax_id', '=', 'taxes.id', false)
            ->where('quotation_line_taxes.line_id', '=', $quotation_line_id)
            ->fetchAll();
    }

    /**
     * Fetches all taxes for the given quotation
     *
     * @param int $quotation_id The ID of the quotation to fetch
     * @return array An array of all tax rules for this quotation
     */
    private function getTaxes($quotation_id)
    {
        $fields = ['taxes.*', 'quotation_line_taxes.cascade', 'quotation_line_taxes.subtract'];

        // Taxes are retrieved regardless of the client's tax status since the quotation itself determines that
        return $this->Record->select($fields)
            ->select(['TRIM(taxes.amount)+?' => 'amount'], false)
            ->appendValues([0])
            ->from('quotations')
            ->innerJoin('quotation_lines', 'quotation_lines.quotation_id', '=', 'quotations.id', false)
            ->innerJoin('quotation_line_taxes', 'quotation_line_taxes.line_id', '=', 'quotation_lines.id', false)
            ->innerJoin('taxes', 'taxes.id', '=', 'quotation_line_taxes.tax_id', false)
            ->where('quotations.id', '=', $quotation_id)
            ->group(['taxes.id'])
            ->order(['level' => 'asc'])
            ->fetchAll();
    }

    /**
     * Fetches a list of quotations for a client
     *
     * @param int $client_id The client ID (optional, default null to get quotations for all clients)
     * @param string $status The status type of the quotations to fetch (optional, default 'draft') one of the following:
     *
     *  - approved Fetches all approved quotations
     *  - pending Fetches all pending quotations
     *  - expired Fetches all expired quotations
     *  - invoiced Fetches all invoiced quotations
     *  - dead Fetches all quotations with a status of "quotations"
     *  - lost Fetches all quotations with a status of "lost"
     *  - draft Fetches all quotations with a status of "draft"
     *  - all Fetches all quotations
     * @param int $page The page to return results for (optional, default 1)
     * @param array $order_by The sort and order conditions (e.g. array('sort_field'=>"ASC"), optional)
     * @param array $filters A list of parameters to filter by, including:
     *
     *  - quotation_number The quotation number on which to filter quotations
     *  - currency The currency code on which to filter quotations
     *  - description The (partial) description on which to filter quotations
     *  - quotation_line The (partial) description of the quotation line on which to filter quotations
     * @return array An array of stdClass objects containing quotation information, or false if no quotations exist
     */
    public function getList(
        $client_id = null,
        $status = 'draft',
        $page = 1,
        $order_by = ['date_expires' => 'ASC'],
        array $filters = []
    ) {
        // If sorting by ID code, use id code sort mode
        if (isset($order_by['id_code']) && Configure::get('Blesta.id_code_sort_mode')) {
            $temp = $order_by['id_code'];
            unset($order_by['id_code']);

            foreach ((array) Configure::get('Blesta.id_code_sort_mode') as $key) {
                $order_by[$key] = $temp;
            }
        }

        $this->Record = $this->getQuotations(array_merge(['client_id' => $client_id, 'status' => $status], $filters));

        // Return the results
        return $this->Record->order($order_by)->
            limit($this->getPerPage(), (max(1, $page) - 1) * $this->getPerPage())->fetchAll();
    }

    /**
     * Returns the total number of quotations returned from Quotations::getClientList(), useful
     * in constructing pagination for the getList() method.
     *
     * @param int $client_id The client ID (optional, default null to get quotation count for all clients)
     * @param string $status The status type of the quotations to fetch (optional, default 'draft') one of the following:
     *
     *  - approved Fetches all approved quotations
     *  - pending Fetches all pending quotations
     *  - expired Fetches all expired quotations
     *  - invoiced Fetches all invoiced quotations
     *  - dead Fetches all quotations with a status of "quotations"
     *  - lost Fetches all quotations with a status of "lost"
     *  - draft Fetches all quotations with a status of "draft"
     *  - all Fetches all quotations
     * @param array $filters A list of parameters to filter by, including:
     *
     *  - quotation_number The quotation number on which to filter quotations
     *  - currency The currency code on which to filter quotations
     *  - quotation_line The (partial) description of the quotation line on which to filter quotations
     * @return int The total number of quotations
     * @see Quotations::getList()
     */
    public function getListCount($client_id = null, $status = 'draft', array $filters = [])
    {
        $this->Record = $this->getQuotations(array_merge(['client_id' => $client_id, 'status' => $status], $filters));

        return $this->Record->numResults();
    }

    /**
     * Fetches all quotations for a client
     *
     * @param int $client_id The client ID (optional, default null to get quotations for all clients)
     * @param string $status The status type of the quotations to fetch (optional, default 'draft') one of the following:
     *
     *  - approved Fetches all approved quotations
     *  - pending Fetches all pending quotations
     *  - expired Fetches all expired quotations
     *  - invoiced Fetches all invoiced quotations
     *  - dead Fetches all quotations with a status of "quotations"
     *  - lost Fetches all quotations with a status of "lost"
     *  - draft Fetches all quotations with a status of "draft"
     *  - all Fetches all quotations
     * @param array $order_by The sort and order conditions (e.g. array('sort_field'=>"ASC"), optional)
     * @param string $currency The currency code to limit results on (null = any currency)
     * @return array An array of stdClass objects containing quotation information
     */
    public function getAll(
        $client_id = null,
        $status = 'draft',
        $order_by = ['date_expires' => 'ASC'],
        $currency = null
    ) {
        $this->Record = $this->getQuotations(['client_id' => $client_id, 'status' => $status]);

        if ($currency !== null) {
            $this->Record->where('currency', '=', $currency);
        }

        return $this->Record->order($order_by)->fetchAll();
    }

    /**
     * Retrieves the number of quotations given an quotation status for the given client
     *
     * @param int $client_id The client ID (optional, default null to get invoice count for company)
     * @param string $status The status type of the quotations to fetch (optional, default 'draft') one of the following:
     *
     *  - approved Fetches all approved quotations
     *  - pending Fetches all pending quotations
     *  - expired Fetches all expired quotations
     *  - invoiced Fetches all invoiced quotations
     *  - dead Fetches all quotations with a status of "quotations"
     *  - lost Fetches all quotations with a status of "lost"
     *  - draft Fetches all quotations with a status of "draft"
     *  - all Fetches all quotations
     * @param array $filters A list of parameters to filter by, including:
     *
     *  - invoice_number The invoice number on which to filter invoices
     *  - currency The currency code on which to filter invoices
     *  - quotation_line The (partial) description of the quotation line on which to filter quotations
     * @return int The number of invoices of type $status for $client_id
     */
    public function getStatusCount($client_id = null, $status = 'draft', array $filters = [])
    {
        return $this->getQuotations(array_merge($filters, ['client_id' => $client_id, 'status' => $status]))
            ->numResults();
    }

    /**
     * Search quotations
     *
     * @param string $query The value to search quotations for
     * @param int $page The page number of results to fetch (optional, default 1)
     * @return array An array of quotations that match the search criteria
     */
    public function search($query, $page = 1)
    {
        $this->Record = $this->searchQuotations($query);

        // Set order by clause
        $order_by = [];
        if (Configure::get('Blesta.id_code_sort_mode')) {
            foreach ((array)Configure::get('Blesta.id_code_sort_mode') as $key) {
                $order_by[$key] = 'ASC';
            }
        } else {
            $order_by = ['date_expires' => 'ASC'];
        }

        return $this->Record->order($order_by)->
            limit($this->getPerPage(), (max(1, $page) - 1) * $this->getPerPage())->
            fetchAll();
    }

    /**
     * Return the total number of quotations returned from Quotations::search(), useful
     * in constructing pagination
     *
     * @param string $query The value to search quotations for
     * @see Quotations::search()
     */
    public function getSearchCount($query)
    {
        $this->Record = $this->searchInvoices($query);

        return $this->Record->numResults();
    }

    /**
     * Partially constructs the query for searching quotations
     *
     * @param string $query The value to search quotations for
     * @return Record The partially constructed query Record object
     * @see Quotations::search(), Quotations::getSearchCount()
     */
    private function searchQuotations($query)
    {
        $this->Record = $this->getQuotations(['status' => 'all']);

        $this->Record->leftJoin('quotation_lines', 'quotation_lines.quotation_id', '=', 'quotations.id', false)
            ->open()
                ->where('quotations.id', '=', $query)
                ->orLike(
                    "CONVERT(REPLACE(quotations.id_format, '"
                    . ($this->replacement_keys['quotations']['ID_VALUE_TAG'] ?? '{num}')
                    . "', quotations.id_value) USING utf8)",
                    '%' . $query . '%',
                    true,
                    false
                )
                ->orLike(
                    "REPLACE(clients.id_format, '"
                    . ($this->replacement_keys['clients']['ID_VALUE_TAG'] ?? '{num}')
                    . "', clients.id_value)",
                    '%' . $query . '%',
                    true,
                    false
                )
                ->orLike('contacts.company', '%' . $query . '%')
                ->orLike("CONCAT_WS(' ', contacts.first_name, contacts.last_name)", '%' . $query . '%', true, false)
                ->orLike('contacts.address1', '%' . $query . '%')
                ->orLike('contacts.email', '%' . $query . '%')
                ->orLike('quotation_lines.description', '%' . $query . '%')
                ->orLike('quotations.notes', '%' . $query . '%')
            ->close()
            ->group(['quotations.id']);

        return $this->Record;
    }

    /**
     * Returns all invoices associated to a quotation
     *
     * @param int $quotation_id The ID of the quotation to fecth the invoices
     * @return array An array containing the invoices associated to the quotation
     */
    public function getInvoices($quotation_id)
    {
        return $this->Record->select([
            'invoices.*',
            'REPLACE(invoices.id_format, ?, invoices.id_value)' => 'id_code',
            'quotation_invoices.quotation_id'
        ])
            ->appendValues(
                [
                    $this->replacement_keys['quotations']['ID_VALUE_TAG'] ?? '{num}'
                ]
            )
            ->from('quotation_invoices')
            ->innerJoin('invoices', 'invoices.id', '=', 'quotation_invoices.invoice_id', false)
            ->where('quotation_invoices.quotation_id', '=', $quotation_id)
            ->fetchAll();
    }

    /**
     * Generates an invoice from an existing quotation
     *
     * @param $quotation_id
     * @param array $vars An array of quotation data (all optional unless noted otherwise) including:
     *
     *  - percentage_due The (%) percentage due in the first payment (optional)
     *  - first_due_date The due date of the first invoice
     *  - second_due_date The due date of the second invoice (optional)
     */
    public function generateInvoice($quotation_id, array $vars)
    {
        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        $quotation = $this->get($quotation_id);

        // Do nothing if the quotation isn't approved or pending
        if (!in_array($quotation->status, ['approved', 'pending'])) {
            $this->Input->setErrors(['status' => ['valid' => $this->_('Quotations.!error.status.valid', true)]]);

            return;
        }

        $this->Input->setRules($this->getInvoiceRules($vars));
        if ($this->Input->validates($vars)) {
            // Generate the first invoice
            $first_items = [];
            foreach ($quotation->line_items as $line_item) {
                $first_items[] = [
                    'description' => $line_item->description,
                    'qty' => $line_item->qty,
                    'amount' => $line_item->amount * $vars['percentage_due'],
                    'tax' => !empty($line_item->taxes)
                ];
            }

            $invoice_id = $this->Invoices->add([
                'client_id' => $quotation->client_id,
                'date_billed' => date('c'),
                'date_due' => $vars['first_due_date'],
                'currency' => $quotation->currency,
                'lines' => $first_items
            ]);

            if (($errors = $this->Invoices->errors())) {
                $this->Input->setErrors($errors);

                return;
            }

            $this->Record->insert('quotation_invoices', ['quotation_id' => $quotation_id, 'invoice_id' => $invoice_id]);

            // Generate second invoice, if any
            if (!empty($vars['second_due_date'])) {
                $remaining_percentage = abs(1 - $vars['percentage_due']);

                if ($remaining_percentage > 0) {
                    $second_items = [];
                    foreach ($quotation->line_items as $line_item) {
                        $second_items[] = [
                            'description' => $line_item->description,
                            'qty' => $line_item->qty,
                            'amount' => $line_item->amount * $remaining_percentage,
                            'tax' => !empty($line_item->taxes)
                        ];
                    }

                    $invoice_id = $this->Invoices->add([
                        'client_id' => $quotation->client_id,
                        'date_billed' => date('c'),
                        'date_due' => $vars['second_due_date'],
                        'currency' => $quotation->currency,
                        'lines' => $second_items
                    ]);

                    if (($errors = $this->Invoices->errors())) {
                        $this->Input->setErrors($errors);

                        return;
                    }

                    $this->Record->insert('quotation_invoices', ['quotation_id' => $quotation_id, 'invoice_id' => $invoice_id]);
                }
            }

            if ($invoice_id) {
                $this->updateStatus($quotation_id, 'invoiced');
            }

            return !empty($invoice_id);
        }

        return false;
    }

    /**
     * Partially constructs the query required by Quotations::getList() and
     * Quotations::getListCount()
     *
     * @param array $filters A list of parameters to filter by, including:
     *
     *  - client_id The client ID (optional, default null to fetch quotations for all clients)
     *  - quotation_number The quotation number on which to filter quotations
     *  - currency The currency code on which to filter quotations
     *  - quotation_line The (partial) description of the quotation line on which to filter quotations
     *  - status The status type of the quotations to fetch (optional, default 'open') one of the following:
     *      - approved Fetches all approved quotations
     *      - pending Fetches all pending quotations
     *      - expired Fetches all expired quotations
     *      - invoiced Fetches all invoiced quotations
     *      - dead Fetches all quotations with a status of "quotations"
     *      - lost Fetches all quotations with a status of "lost"
     *      - draft Fetches all quotations with a status of "draft"
     *      - all Fetches all quotations
     * @param array $options A list of additional options
     *
     *  - client_group_id The ID of the client group to filter quotations on
     * @return Record The partially constructed query Record object
     */
    private function getQuotations(array $filters = [], array $options = [])
    {
        if (empty($filters['status'])) {
            $filters['status'] = 'draft';
        }

        $fields = ['quotations.*',
            'REPLACE(quotations.id_format, ?, quotations.id_value)' => 'id_code',
            'REPLACE(clients.id_format, ?, clients.id_value)' => 'client_id_code',
            'contacts.first_name' => 'client_first_name',
            'contacts.last_name' => 'client_last_name',
            'contacts.company' => 'client_company',
            'contacts.address1' => 'client_address1',
            'contacts.email' => 'client_email',
            'staff.first_name' => 'staff_first_name',
            'staff.last_name' => 'staff_last_name',
            'staff.email' => 'staff_email',
        ];

        // Fetch the quotations along with total due and total paid, calculate total remaining on the fly
        $this->Record->select($fields)
            ->appendValues(
                [
                    $this->replacement_keys['quotations']['ID_VALUE_TAG'] ?? '{num}',
                    $this->replacement_keys['clients']['ID_VALUE_TAG'] ?? '{num}'
                ]
            )
            ->from('quotations')
            ->innerJoin('clients', 'clients.id', '=', 'quotations.client_id', false)
            ->innerJoin('staff', 'staff.id', '=', 'quotations.staff_id', false)
            ->innerJoin('client_groups', 'client_groups.id', '=', 'clients.client_group_id', false)
            ->on('contacts.contact_type', '=', 'primary')
            ->innerJoin('contacts', 'contacts.client_id', '=', 'clients.id', false);

        // Filter on quotation number
        if (!empty($filters['quotation_number'])) {
            $this->Record->having('id_code', 'like', '%' . $filters['quotation_number'] . '%');
        }

        // Filter on quotation currency
        if (!empty($filters['currency'])) {
            $this->Record->where('quotations.currency', '=', $filters['currency']);
        }

        // Filter on quotation lines content
        if (!empty($filters['quotation_line'])) {
            $this->Record->on('quotation_lines.description', 'LIKE', '%' . $filters['quotation_line'] . '%')
                ->innerJoin('quotation_lines', 'quotation_lines.quotation_id', '=', 'quotations.id', false);
        }

        // Get quotations by status
        if ($filters['status'] !== 'all') {
            $this->Record->where('quotations.status', '=', $filters['status']);
        }

        // Filter by client group ID
        if (isset($options['client_group_id'])) {
            $this->Record->where('client_groups.id', '=', $options['client_group_id']);
        }

        // Filter by company
        $this->Record->where('client_groups.company_id', '=', Configure::get('Blesta.company_id'));

        // Get for a specific client
        if (!empty($filters['client_id'])) {
            $this->Record->where('quotations.client_id', '=', $filters['client_id']);
        }

        // Get for a specific staff member
        if (!empty($filters['staff_id'])) {
            $this->Record->where('quotations.staff_id', '=', $filters['staff_id']);
        }

        return $this->Record;
    }

    /**
     * Retrieves a presenter representing a set of items and taxes for the quotation
     *
     * @param int $quotation_id The ID of the quotation whose pricing to fetch
     * @return bool|Blesta\Core\Pricing\Presenter\Type\InvoicePresenter The presenter, otherwise false
     */
    public function getPresenter($quotation_id)
    {
        Loader::loadModels($this, ['Companies']);
        Loader::loadComponents($this, ['SettingsCollection']);

        // We must have an quotation
        if (!($quotation = $this->getQuotation($quotation_id)->fetch())) {
            return false;
        }

        // Set the line items
        $quotation->line_items = $this->getLines($quotation_id);

        // Retrieve the pricing builder from the container and update the date format options
        $container = Configure::get('container');
        $container['pricing.options'] = [
            'dateFormat' => $this->Companies->getSetting(Configure::get('Blesta.company_id'), 'date_format')
                ->value,
            'dateTimeFormat' => $this->Companies->getSetting(Configure::get('Blesta.company_id'), 'datetime_format')
                ->value
        ];

        $factory = $this->getFromContainer('pricingBuilder');
        $quotationBuilder = $factory->invoice();

        // Build the quotation presenter
        $quotationBuilder->settings($this->SettingsCollection->fetchClientSettings($quotation->client_id));
        $quotationBuilder->taxes($this->getTaxes($quotation_id));

        return $quotationBuilder->build($quotation);
    }

    /**
     * Retrieves a presenter representing a set of items and taxes for quotation data
     *
     * @param int $client_id The ID of the client the quotation data is for
     * @param array $vars An array of input representing the new quotation data
     *
     *  - client_id The client ID the quote belongs to
     *  - staff_id The staff ID the quote was generated by
     *  - title The title of the quotation
     *  - status The status of the quotation, it could be 'draft', 'pending', 'approved',
     *      'invoiced', 'expired', 'dead' or 'lost'
     *  - currency The ISO 4217 3-character currency code of the quote
     *  - notes Notes visible to the client
     *  - date_created The date the quotation was created
     *  - date_expires The date the quotation expires
     *  - lines A numerically indexed array of line item info including:
     *      - description The line item description
     *      - qty The quantity for this line item (min. 1)
     *      - amount The unit cost (cost per quantity) for this line item
     *      - tax Whether or not to tax the line item
     * @return Blesta\Core\Pricing\Presenter\Type\InvoiceDataPresenter The presenter
     */
    public function getDataPresenter($client_id, array $vars)
    {
        // Set the client ID into the vars
        $vars['client_id'] = $client_id;

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }
        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }
        if (!isset($this->SettingsCollection)) {
            Loader::loadComponents($this, ['SettingsCollection']);
        }

        // Retrieve the pricing builder from the container and update the date format options
        $container = Configure::get('container');
        $container['pricing.options'] = [
            'dateFormat' => $this->Companies->getSetting(Configure::get('Blesta.company_id'), 'date_format')
                ->value,
            'dateTimeFormat' => $this->Companies->getSetting(Configure::get('Blesta.company_id'), 'datetime_format')
                ->value
        ];

        $factory = $this->getFromContainer('pricingBuilder');
        $quotationBuilder = $factory->invoiceData();

        // Build the quotation presenter
        $quotationBuilder->settings($this->SettingsCollection->fetchClientSettings($client_id));
        $quotationBuilder->taxes($this->Invoices->getTaxRules($client_id));

        return $quotationBuilder->build($vars);
    }

    /**
     * Updates $vars with the subqueries to properly set the id_format and id_value fields
     * when creating a quotation
     *
     * @param array $vars An array of quote data from Quotations::add() or Quotations::edit()
     * @param array $client_settings An array of client settings
     * @return array An array of quote data now including the proper
     *  subqueries for setting the id_format and id_value fields
     */
    private function getNextQuotationVars(array $vars, array $client_settings)
    {
        $quotation_format = $client_settings['quotation_format'];
        $quotation_start = $client_settings['quotation_start'];
        $quotation_increment = $client_settings['quotation_increment'];

        // Set default status
        if (!isset($vars['status'])) {
            $vars['status'] = 'draft';
        }

        // Set the id format accordingly, also replace the {year} tag with the appropriate year,
        // the {month} tag with the appropriate month, and the {day} tag with the appropriate day
        // to ensure the id_value is calculated appropriately on a year-by-year basis
        $tags = ['{year}', '{month}', '{day}'];
        $replacements = [$this->Date->format('Y'), $this->Date->format('m'), $this->Date->format('d')];
        $vars['id_format'] = str_ireplace($tags, $replacements, $quotation_format);

        // Creates subquery to calculate the next quotation ID value on the fly
        $sub_query = new Record();
        $values = [$quotation_start, $quotation_increment, $quotation_start];

        $sub_query->select(
            ['IFNULL(GREATEST(MAX(t1.id_value),CAST(? AS UNSIGNED))+CAST(? AS UNSIGNED),CAST(? AS UNSIGNED))'],
            false
        )->
            appendValues($values)->
            from(['quotations' => 't1'])->
            innerJoin('clients', 'clients.id', '=', 't1.client_id', false)->
            innerJoin('client_groups', 'client_groups.id', '=', 'clients.client_group_id', false)->
            where('client_groups.company_id', '=', Configure::get('Blesta.company_id'))->
            where('t1.id_format', '=', $vars['id_format']);
        // run get on the query so $sub_query->values are built
        $sub_query_string = $sub_query->get();

        // Convert subquery into sub-sub query to force MySQL to create a temporary table
        // to avoid conflicts with reading/writing from the "quotations" table simultaneously
        $query = new Record();
        $query->values = $sub_query->values;
        $query->select('t11.*')->from([$sub_query_string => 't11']);

        // id_value will be calculated on the fly using a subquery
        $vars['id_value'] = $query;

        // Delete empty lines
        foreach ($vars['lines'] ?? [] as $key => $line) {
            if (empty($line)) {
                unset($vars['lines'][$key]);
            }
        }

        return $vars;
    }

    /**
     * Adds a new line item tax
     *
     * @param int $line_id The line ID
     * @param int $tax_id The tax ID
     * @param bool $cascade Whether or not this tax rule should cascade over other rules
     * @param bool $subtract Whether or not this tax rule should be subtracted from the line item value
     */
    private function addLineTax($line_id, $tax_id, $cascade = false, $subtract = false)
    {
        $this->Record->insert(
            'quotation_line_taxes',
            [
                'line_id' => $line_id,
                'tax_id' => $tax_id,
                'cascade' => ($cascade ? 1 : 0),
                'subtract' => ($subtract ? 1 : 0)
            ]
        );
    }

    /**
     * Returns the rule set for adding/editing quotations
     *
     * @param array $vars The input vars
     * @return array Quotations rules
     */
    private function getRules(array $vars)
    {
        Loader::loadModels($this, ['Invoices']);

        $rules = [
            // Quotation rules
            'id_format' => [
                'empty' => [
                    'rule' => 'isEmpty',
                    'negate' => true,
                    'message' => $this->_('Quotations.!error.id_format.empty')
                ],
                'length' => [
                    'rule' => ['maxLength', 64],
                    'message' => $this->_('Quotations.!error.id_format.length')
                ]
            ],
            'id_value' => [
                'valid' => [
                    'rule' => [[$this->Invoices, 'isInstanceOf'], 'Record'],
                    'message' => $this->_('Quotations.!error.id_value.valid')
                ]
            ],
            'client_id' => [
                'exists' => [
                    'rule' => [[$this, 'validateExists'], 'id', 'clients'],
                    'message' => $this->_('Quotations.!error.client_id.exists')
                ]
            ],
            'staff_id' => [
                'exists' => [
                    'rule' => [[$this, 'validateExists'], 'id', 'staff'],
                    'message' => $this->_('Quotations.!error.staff_id.exists')
                ]
            ],
            'title' => [
                'empty' => [
                    'rule' => 'isEmpty',
                    'negate' => true,
                    'message' => $this->_('Quotations.!error.title.empty')
                ],
                'length' => [
                    'rule' => ['maxLength', 255],
                    'message' => $this->_('Quotations.!error.title.length')
                ]
            ],
            'status' => [
                'format' => [
                    'if_set' => true,
                    'rule' => ['in_array', array_keys($this->getStatuses())],
                    'message' => $this->_('Quotations.!error.status.format')
                ]
            ],
            'currency' => [
                'length' => [
                    'rule' => ['matches', '/^[A-Z]{3}$/'],
                    'message' => $this->_('Quotations.!error.currency.length')
                ]
            ],
            'date_created' => [
                'format' => [
                    'rule' => 'isDate',
                    'message' => $this->_('Quotations.!error.date_created.format'),
                    'post_format' => [[$this, 'dateToUtc']]
                ]
            ],
            'date_expires' => [
                'format' => [
                    'rule' => 'isDate',
                    'message' => $this->_('Quotations.!error.date_expires.format')
                ],
                'after_created' => [
                    'rule' => [[$this, 'validateDateDueAfterDateCreated'], ($vars['date_expires'] ?? null)],
                    'message' => $this->_('Quotations.!error.date_expires.after_created'),
                    'post_format' => [[$this, 'dateToUtc']]
                ]
            ],
            // Quotation line item rules
            'lines[][description]' => [
                'empty' => [
                    'rule' => 'isEmpty',
                    'negate' => true,
                    'message' => $this->_('Quotations.!error.lines[][description].empty')
                ]
            ],
            'lines[][qty]' => [
                'minimum' => [
                    'pre_format' => [[$this->Invoices, 'primeQuantity']],
                    'if_set' => true,
                    'rule' => 'is_scalar',
                    'message' => $this->_('Quotations.!error.lines[][qty].minimum')
                ]
            ],
            'lines[][amount]' => [
                'format' => [
                    'if_set' => true,
                    'pre_format' => [[$this, 'currencyToDecimal'], $vars['currency'], 4],
                    'rule' => 'is_numeric',
                    'message' => $this->_('Quotations.!error.lines[][amount].format')
                ]
            ],
            'lines[][tax]' => [
                'format' => [
                    'if_set' => true,
                    'pre_format' => [[$this, 'strToBool']],
                    'rule' => 'is_bool',
                    'message' => $this->_('Quotations.!error.lines[][tax].format')
                ]
            ],
            // Quotation delivery rules
            'delivery' => [
                'exists' => [
                    'if_set' => true,
                    'rule' => [[$this->Invoices, 'validateDeliveryMethods']],
                    'message' => $this->_('Quotations.!error.delivery.exists')
                ]
            ]
        ];

        return $rules;
    }

    /**
     * Returns the rule set for adding/editing quotations
     *
     * @param array $vars The input vars
     * @return array Quotations rules
     */
    private function getInvoiceRules(array $vars)
    {
        Loader::loadModels($this, ['Invoices']);

        $rules = [
            'first_due_date' => [
                'format' => [
                    'rule' => 'isDate',
                    'message' => $this->_('Quotations.!error.first_due_date.format'),
                    'post_format' => [[$this, 'dateToUtc']]
                ]
            ],
            'second_due_date' => [
                'format' => [
                    'if_set' => true,
                    'rule' => 'isDate',
                    'message' => $this->_('Quotations.!error.second_due_date.format'),
                    'post_format' => [[$this, 'dateToUtc']]
                ]
            ],
            'percentage_due' => [
                'format' => [
                    'rule' => 'is_numeric',
                    'message' => $this->_('Quotations.!error.percentage_due.format')
                ],
                'valid' => [
                    'rule' => function ($percentage_due) {
                        return $percentage_due > 0 && $percentage_due <= 100;
                    },
                    'message' => $this->_('Quotations.!error.percentage_due.valid'),
                    'post_format' => function ($percentage_due) {
                        return $percentage_due / 100;
                    }
                ]
            ]
        ];

        return $rules;
    }

    /**
     * Validates that the given date due is on or after the date created
     *
     * @param string $date_expires The date the quotation expires
     * @param string $date_created The date the quotation was created
     * @return bool True if the date due is on or after the date created, false otherwise
     */
    public function validateDateDueAfterDateCreated($date_expires, $date_created)
    {
        if (!empty($date_expires) && !empty($date_created)) {
            if (strtotime($date_expires) < strtotime($date_created)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Identifies whether or not the given quotation with its updated line items and deleted items
     * requires tax rules to be updated when saved. This method doesn't check whether the tax
     * rules have been updated, just whether the invoice has been changed such that the updated
     * tax rules would need to be updated. There's no consequence in updating tax when
     * the tax rules have not changed.
     *
     * @param int $quotation_id The ID of the invoice to evaluate
     * @param array $lines An array of line items including:
     *
     *  - id The ID of the line item (if available)
     *  - tax Whether or not the line items is taxable (true/false)
     *  - amount The amount per quantity for the line item
     *  - qty The quantity of the line item
     * @param array $delete_items An array of items to be deleted from the invoice
     * @return bool True if the invoice has been modified in such a way to
     *  warrant updating the tax rules applied, false otherwise
     * @see Quotations::edit()
     */
    private function taxUpdateRequired($quotation_id, $lines, $delete_items)
    {
        $tax_change = false;

        $quotation = $this->get($quotation_id);
        $num_lines = is_array($lines) ? count($lines) : 0;
        $num_delete = is_array($delete_items) ? count($delete_items) : 0;

        // Ensure the quotation exists
        if (!$quotation) {
            return $tax_change;
        }

        // If any new items added or any items removed, taxes must be updated
        if (count($quotation->line_items) != $num_lines || $num_delete > 0) {
            $tax_change = true;
        } else {
            // Ensure that quantity, unit cost, and tax status remain unchanged
            for ($i = 0; $i < $num_lines; $i++) {
                if (isset($lines[$i]['id'])) {
                    for ($j = 0; $j < $num_lines; $j++) {
                        // Ensure tax status remains unchanged
                        if ($quotation->line_items[$j]->id == $lines[$i]['id']) {
                            if (
                                (!$lines[$i]['tax'] && !empty($quotation->line_items[$j]->taxes)) ||
                                ($lines[$i]['tax'] && empty($quotation->line_items[$j]->taxes))
                            ) {
                                $tax_change = true;
                                break 2;
                            }

                            // Ensure amount and quantity remain unchanged
                            if (
                                $lines[$i]['amount'] != $quotation->line_items[$j]->amount ||
                                $lines[$i]['qty'] != $quotation->line_items[$j]->qty
                            ) {
                                $tax_change = true;
                                break 2;
                            }
                        }
                    }
                }
            }
        }

        return $tax_change;
    }

    /**
     * Adds a line item to an existing quotation
     *
     * @param int $quotation_id The ID of the quotation to add a line item to
     * @param array $vars A list of line item vars including:
     *
     *  - description The line item description
     *  - qty The quantity for this line item (min. 1)
     *  - amount The unit cost (cost per quantity) for this line item
     *  - tax Whether or not to tax the line item
     *  - order The order number of the line item (optional, default is the last)
     * @return int The ID of the line item created
     */
    private function addLine($quotation_id, array $vars)
    {
        $line = $vars;
        $line['quotation_id'] = $quotation_id;

        // Calculate the next line item order off of this invoice
        if (!isset($vars['order'])) {
            $order = $this->Record->select(['MAX(order)' => 'order'])->
            from('quotation_lines')->
            where('quotation_id', '=', $quotation_id)->
            fetch();

            $line['order'] = isset($order->order) ? $order->order + 1 : 0;
        }

        // Insert a new line item
        $fields = ['quotation_id', 'description', 'qty', 'amount', 'order'];
        $this->Record->insert('quotation_lines', $line, $fields);

        return $this->Record->lastInsertId();
    }

    /**
     * Permanently removes a quotation line item and its corresponding line item taxes
     *
     * @param int $line_id The line item ID
     */
    private function deleteLine($line_id)
    {
        // Delete line item
        $this->Record->from('quotation_lines')->where('id', '=', $line_id)->delete();

        // Delete line item taxes
        $this->deleteLineTax($line_id);
    }

    /**
     * Permanently removes a quotation line item's tax rule
     *
     * @param int $line_id The line item ID
     */
    private function deleteLineTax($line_id)
    {
        // Delete line item taxes
        $this->Record->from('quotation_line_taxes')->where('line_id', '=', $line_id)->delete();
    }

    /**
     * Fetch a cached quotation
     *
     * @param int $quotation_id The ID of the quotation to fetch
     * @param string $extension The cache extension (optional, 'json' by default)
     * @param string $language The language of the quotation to fetch (optional)
     * @return mixed An object containing the quotation data for JSON, a stream of binary data for PDF and false on error
     */
    public function fetchCache($quotation_id, $extension = 'json', $language = null)
    {
        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->SettingsCollection)) {
            Loader::loadComponents($this, ['SettingsCollection']);
        }

        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        // Check if the provided extension is valid
        if (!in_array($extension, $this->Invoices->cacheExtensions())) {
            return false;
        }

        // Set quotation language
        if (is_null($language)) {
            $language = Configure::get('Language.default');
        }

        // Fetch the data
        $company_id = Configure::get('Blesta.company_id');
        $uploads_dir = $this->SettingsCollection->fetchSetting($this->Companies, $company_id, 'uploads_dir');

        // There is nothing cached without an uploads directory
        if (empty($uploads_dir['value'])) {
            return false;
        }

        $cache = rtrim($uploads_dir['value'], DS) . DS . $company_id
            . DS . 'quotations' . DS . md5('quotation_' . $quotation_id . $language) . '.' . $extension;

        // Fetch company settings
        if (file_exists($cache)) {
            $data = file_get_contents($cache);

            if ($extension == 'pdf' && function_exists('gzuncompress')) {
                try {
                    $uncompressed_data = gzuncompress($data);

                    if ($uncompressed_data !== false) {
                        $data = $uncompressed_data;
                    }
                } catch (Throwable $e) {
                    // The PDF is not compressed, nothing to do
                }
            } elseif ($extension == 'json') {
                $data = (object) json_decode($data);

                if (isset($data->client->settings)) {
                    $data->client->settings = (array) $data->client->settings;
                }

                if (isset($data->company_settings)) {
                    $data->company_settings = (array) $data->company_settings;
                }
            }

            return $data;
        } else {
            return false;
        }
    }

    /**
     * Clears the quotation cache
     *
     * @param int $quotation_id The ID of the quotation to clear
     * @param string $extension The cache extension (optional, 'json' by default)
     * @param string $language The language of the quotation to clear (optional)
     * @return bool True if the quotation cache has been deleted successfully, false otherwise
     */
    public function clearCache($quotation_id, $extension = 'json', $language = null)
    {
        if (!isset($this->Languages)) {
            Loader::loadModels($this, ['Languages']);
        }

        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        // Check if the provided extension is valid
        if (!in_array($extension, $this->Invoices->cacheExtensions())) {
            return false;
        }

        $company_id = Configure::get('Blesta.company_id');
        $uploads_dir = $this->SettingsCollection->fetchSetting($this->Companies, $company_id, 'uploads_dir');

        // There is nothing cached without an uploads directory
        if (empty($uploads_dir['value'])) {
            return false;
        }

        $cache_cleared = false;
        $languages = $this->Languages->getAll($company_id);
        foreach ($languages as $language_obj) {
            // Set quotation language
            if (!is_null($language) && $language_obj->code != $language) {
                continue;
            }

            $cache = rtrim($uploads_dir['value'], DS) . DS . $company_id
                . DS . 'quotations' . DS . md5('quotation_' . $quotation_id . $language_obj->code) . '.' . $extension;

            if (is_file($cache)) {
                @unlink($cache);

                $cache_cleared = true;
            }
        }

        return $cache_cleared;
    }

    /**
     * Writes a quotation on cache
     *
     * @param int $quotation_id The ID of the quotation to save on cache
     * @param mixed $data The data of the quotation to cache
     * @param string $extension The cache extension (optional, 'json' by default)
     * @param string $language The language of the quotation being saved (optional)
     * @return bool True if the quotation has been saved on cache successfully, void on error
     */
    public function writeCache($quotation_id, $data, $extension = 'json', $language = null)
    {
        if (!isset($this->Companies)) {
            Loader::loadModels($this, ['Companies']);
        }

        if (!isset($this->SettingsCollection)) {
            Loader::loadComponents($this, ['SettingsCollection']);
        }

        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        // Check if the provided extension is valid
        if (!in_array($extension, $this->Invoices->cacheExtensions())) {
            return false;
        }

        // Set quotation language
        if (is_null($language)) {
            $language = Configure::get('Language.default');
        }

        // Check if the quotation has been cached previously
        $cached_quotation = $this->fetchCache($quotation_id, $extension, $language);

        if (!empty($cached_quotation)) {
            return true;
        }

        // Fetch company settings
        $company_id = Configure::get('Blesta.company_id');
        $company_settings = $this->SettingsCollection->fetchSettings($this->Companies, $company_id);

        // There is nowhere to cache to without an uploads directory
        if (empty($company_settings['uploads_dir'])) {
            return false;
        }

        // Create the cache folder if does not exists
        $cache = rtrim($company_settings['uploads_dir'], DS) . DS . $company_id
            . DS . 'quotations' . DS . md5('quotation_' . $quotation_id . $language) . '.' . $extension;
        $cache_dir = dirname($cache);

        if (!file_exists($cache_dir)) {
            mkdir($cache_dir, 0755, true);
        }

        // Compress the data
        if (
            $extension == 'pdf'
            && $company_settings['inv_cache_compress'] == 'true'
            && function_exists('gzcompress')
        ) {
            // Set the compress level to 4 as it offers the best performance/compression ratio
            $data = gzcompress($data, 4);
        }

        // Encode JSON data
        if (!is_scalar($data) && $extension == 'json') {
            $data = json_encode((object) $data, JSON_PRETTY_PRINT);
        }

        // Save output to cache file
        file_put_contents($cache, $data);

        return true;
    }

    /**
     * Updates a quotation on cache
     *
     * @param int $quotation_id The ID of the quotation to save on cache
     * @param mixed $data The data of the quotation to cache (optional, if not provided
     *  the quotation data will be cached the next time the quotation is rendered)
     * @param string $extension The cache extension (optional, 'json' by default)
     * @return bool True if the quotation has been saved on cache successfully, void on error
     */
    public function updateCache($quotation_id, $data = null, $extension = 'json')
    {
        if (!isset($this->Invoices)) {
            Loader::loadModels($this, ['Invoices']);
        }

        // Check if the provided extension is valid
        if (!in_array($extension, $this->Invoices->cacheExtensions())) {
            return false;
        }

        $this->clearCache($quotation_id, $extension);

        if (!empty($data)) {
            if ($extension == 'json') {
                if (!isset($data->client)) {
                    Loader::loadModels($this, ['Clients']);
                    $data->client = $this->Clients->get($data->client_id);
                }

                if (!isset($data->billing)) {
                    Loader::loadModels($this, ['Contacts', 'Countries']);

                    // Fetch the contact to which quotations should be addressed
                    if (
                        !($billing = $this->Contacts->get((int)$data->client->settings['inv_address_to']))
                        || $billing->client_id != $data->client_id
                    ) {
                        $billing = $this->Contacts->get($data->client->contact_id);
                    }

                    $data->billing = $billing;
                    $data->billing->country = $this->Countries->get($billing->country);
                }

                if (!isset($data->company_settings)) {
                    $data->company_settings = $data->client->settings;
                }

                if (!isset($data->company)) {
                    Loader::loadModels($this, ['Companies']);
                    $data->company = $this->Companies->get($data->client->company_id);
                }
            }

            $this->writeCache($quotation_id, $data, $extension);
        }
    }

    /**
     * Gets the available statuses for quotations
     *
     * @return array An array containing the available statuses for quotations
     */
    public function getStatuses()
    {
        return [
            'approved' => Language::_('Quotations.getstatuses.approved', true),
            'pending' => Language::_('Quotations.getstatuses.pending', true),
            'draft' => Language::_('Quotations.getstatuses.draft', true),
            'invoiced' => Language::_('Quotations.getstatuses.invoiced', true),
            'expired' => Language::_('Quotations.getstatuses.expired', true),
            'dead' => Language::_('Quotations.getstatuses.dead', true),
            'lost' => Language::_('Quotations.getstatuses.lost', true)
        ];
    }
}
