<?php
/**
 * The Payment model.
 *
 * @package WP_Ultimo
 * @subpackage Models
 * @since 2.0.0
 */

namespace WP_Ultimo\Models;

use WP_Ultimo\Models\Base_Model;
use WP_Ultimo\Database\Payments\Payment_Status;
use WP_Ultimo\Checkout\Line_Item;
use WP_Ultimo\Models\Product;
use WP_Ultimo\Models\Customer;
use WP_Ultimo\Models\Membership;

// Exit if accessed directly
defined('ABSPATH') || exit;

/**
 * Payment model class. Implements the Base Model.
 *
 * @since 2.0.0
 */
class Payment extends Base_Model {

	use Traits\Notable;

	/**
	 * ID of the product of this payment.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	protected $product_id;

	/**
	 * ID of the customer attached to this payment.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	protected $customer_id;

	/**
	 * Membership ID.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	protected $membership_id;

	/**
	 * Parent payment.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	protected $parent_id;

	/**
	 * Currency for this payment. 3-letter currency code.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $currency;

	/**
	 * Value before taxes, discounts, fees and etc.
	 *
	 * @since 2.0.0
	 * @var float
	 */
	protected $subtotal = 0;

	/**
	 * Refund total in this payment.
	 *
	 * @since 2.0.0
	 * @var float
	 */
	protected $refund_total = 0;

	/**
	 * The total value in discounts.
	 *
	 * @since 2.0.0
	 * @var integer
	 */
	protected $discount_total = 0;

	/**
	 * The amount, in currency, of the tax.
	 *
	 * @since 2.0.0
	 * @var float
	 */
	protected $tax_total = 0;

	/**
	 * Discount code used.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $discount_code;

	/**
	 * Total value of the payment.
	 *
	 * This takes into account fees, discounts, credits, etc.
	 *
	 * @since 2.0.0
	 * @var float
	 */
	protected $total = 0;

	/**
	 * Status of the status.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $status;

	/**
	 * Gateway used to process this payment.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $gateway;

	/**
	 * ID of the payment on the gateway, if it exists.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $gateway_payment_id;

	/**
	 * Array containing representations of the line items on this payment.
	 *
	 * @since 2.0.0
	 * @var Line_Item[]
	 */
	protected $line_items;

	/**
	 * Sequential invoice number assigned to this payment.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	protected $invoice_number;

	/**
	 * Holds if we need to cancel the membership on refund.
	 *
	 * @since 2.0.0
	 * @var bool
	 */
	protected $cancel_membership_on_refund;

	/**
	 * Query Class to the static query methods.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	protected $query_class = \WP_Ultimo\Database\Payments\Payment_Query::class;

	/**
	 * Adds magic methods to return formatted values automatically.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name Method name.
	 * @param array  $args List of arguments.
	 * @throws \BadMethodCallException Throws exception when method is not found.
	 * @return mixed
	 */
	public function __call($name, $args) {

		$method_key = str_replace('_formatted', '', $name);

		if (str_contains($name, '_formatted') && method_exists($this, $method_key)) {
			return wu_format_currency($this->{"$method_key"}(), $this->get_currency());
		}

		throw new \BadMethodCallException($name);
	}

	/**
	 * Set the validation rules for this particular model.
	 *
	 * To see how to setup rules, check the documentation of the
	 * validation library we are using: https://github.com/rakit/validation
	 *
	 * @since 2.0.0
	 * @link https://github.com/rakit/validation
	 * @return array
	 */
	public function validation_rules(): array {

		$currency = wu_get_setting('currency_symbol', 'USD');

		$payment_types = new \WP_Ultimo\Database\Payments\Payment_Status();

		$payment_types = $payment_types->get_allowed_list(true);

		return [
			'customer_id'                 => 'required|integer|exists:\WP_Ultimo\Models\Customer,id',
			'membership_id'               => 'required|integer|exists:\WP_Ultimo\Models\Membership,id',
			'parent_id'                   => 'integer|default:',
			'currency'                    => "default:{$currency}",
			'subtotal'                    => 'required|numeric',
			'refund_total'                => 'numeric',
			'tax_total'                   => 'numeric',
			'discount_code'               => 'alpha_dash',
			'total'                       => 'required|numeric',
			'status'                      => "required|in:{$payment_types}",
			'gateway'                     => 'default:',
			'gateway_payment_id'          => 'default:',
			'discount_total'              => 'integer',
			'invoice_number'              => 'default:',
			'cancel_membership_on_refund' => 'boolean|default:0',
		];
	}

	/**
	 * Gets the customer object associated with this payment.
	 *
	 * @todo Implement this.
	 * @since 2.0.0
	 * @return \WP_Ultimo\Models\Customer;
	 */
	public function get_customer() {

		return wu_get_customer($this->get_customer_id());
	}

	/**
	 * Get the value of customer_id.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_customer_id(): int {

		return absint($this->customer_id);
	}

	/**
	 * Set the value of customer_id.
	 *
	 * @since 2.0.0
	 * @param int $customer_id The ID of the customer attached to this payment.
	 * @return void
	 */
	public function set_customer_id($customer_id): void {

		$this->customer_id = absint($customer_id);
	}

	/**
	 * Gets the membership object associated with this payment.
	 *
	 * @todo Implement this.
	 * @since 2.0.0
	 * @return \WP_Ultimo\Models\Membership|false
	 */
	public function get_membership() {

		return wu_get_membership($this->get_membership_id());
	}

	/**
	 * Get membership ID.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_membership_id() {

		return $this->membership_id;
	}

	/**
	 * Set membership ID.
	 *
	 * @since 2.0.0
	 * @param int $membership_id The ID of the membership attached to this payment.
	 * @return void
	 */
	public function set_membership_id($membership_id): void {

		$this->membership_id = $membership_id;
	}

	/**
	 * Get parent payment ID.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_parent_id() {

		return $this->parent_id;
	}

	/**
	 * Set parent payment ID.
	 *
	 * @since 2.0.0
	 * @param int $parent_id The ID from another payment that this payment is related to.
	 * @return void
	 */
	public function set_parent_id($parent_id): void {

		$this->parent_id = $parent_id;
	}

	/**
	 * Get currency for this payment. 3-letter currency code.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_currency(): string {

		// return $this->currency; For now, multi-currency is not yet supported.
		return wu_get_setting('currency_symbol', 'USD');
	}

	/**
	 * Set currency for this payment. 3-letter currency code.
	 *
	 * @since 2.0.0
	 * @param string $currency The currency of this payment. It's a 3-letter code. E.g. 'USD'.
	 * @return void
	 */
	public function set_currency($currency): void {

		$this->currency = $currency;
	}

	/**
	 * Get value before taxes, discounts, fees and etc.
	 *
	 * @since 2.0.0
	 * @return float
	 */
	public function get_subtotal(): float {

		return $this->subtotal;
	}

	/**
	 * Set value before taxes, discounts, fees and etc.
	 *
	 * @since 2.0.0
	 * @param float $subtotal Value before taxes, discounts, fees and other changes.
	 * @return void
	 */
	public function set_subtotal($subtotal): void {

		$this->subtotal = $subtotal;
	}

	/**
	 * Get refund total in this payment.
	 *
	 * @since 2.0.0
	 * @return float
	 */
	public function get_refund_total(): float {

		return $this->refund_total;
	}

	/**
	 * Set refund total in this payment.
	 *
	 * @since 2.0.0
	 * @param float $refund_total Total amount refunded.
	 * @return void
	 */
	public function set_refund_total($refund_total): void {

		$this->refund_total = $refund_total;
	}

	/**
	 * Get the amount, in currency, of the tax.
	 *
	 * @since 2.0.0
	 * @return float
	 */
	public function get_tax_total(): float {

		return (float) $this->tax_total;
	}

	/**
	 * Set the amount, in currency, of the tax.
	 *
	 * @since 2.0.0
	 * @param float $tax_total The amount, in currency, of the tax.
	 * @return void
	 */
	public function set_tax_total($tax_total): void {

		$this->tax_total = $tax_total;
	}

	/**
	 * Get discount code used.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_discount_code() {

		return $this->discount_code;
	}

	/**
	 * Set discount code used.
	 *
	 * @since 2.0.0
	 * @param string $discount_code Discount code used.
	 * @return void
	 */
	public function set_discount_code($discount_code): void {

		$this->discount_code = $discount_code;
	}

	/**
	 * Get this takes into account fees, discounts, credits, etc.
	 *
	 * @since 2.0.0
	 * @return float
	 */
	public function get_total(): float {

		return (float) $this->total;
	}

	/**
	 * Set this takes into account fees, discounts, credits, etc.
	 *
	 * @since 2.0.0
	 * @param float $total This takes into account fees, discounts and credits.
	 * @return void
	 */
	public function set_total($total): void {

		$this->total = $total;
	}

	/**
	 * Returns the Label for a given severity level.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_status_label() {

		$status = new Payment_Status($this->get_status());

		return $status->get_label();
	}

	/**
	 * Gets the classes for a given class.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_status_class() {

		$status = new Payment_Status($this->get_status());

		return $status->get_classes();
	}

	/**
	 * Get status of the status.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_status() {

		return $this->status;
	}

	/**
	 * Set status of the status.
	 *
	 * @since 2.0.0
	 * @param string $status The payment status: Can be 'pending', 'completed', 'refunded', 'partially-refunded', 'partially-paid', 'failed', 'cancelled' or other values added by third-party add-ons.
	 * @options \WP_Ultimo\Database\Payments\Payment_Status
	 * @return void
	 */
	public function set_status($status): void {

		$this->status = $status;
	}

	/**
	 * Get gateway used to process this payment.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_gateway() {

		return $this->gateway;
	}

	/**
	 * Set gateway used to process this payment.
	 *
	 * @since 2.0.0
	 * @param string $gateway ID of the gateway being used on this payment.
	 * @return void
	 */
	public function set_gateway($gateway): void {

		$this->gateway = $gateway;
	}

	/**
	 * Returns the payment method used. Usually it is the public name of the gateway.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_payment_method() {

		$gateway = $this->get_gateway();

		if ( ! $gateway) {
			return __('None', 'wp-ultimo');
		}

		$gateway_class = wu_get_gateway($gateway);

		if ( ! $gateway_class) {
			return __('None', 'wp-ultimo');
		}

		$title = $gateway_class->get_public_title();

		return apply_filters("wu_gateway_{$gateway}_as_option_title", $title, $gateway_class);
	}

	/**
	 * Returns the product associated to this payment.
	 *
	 * @since 2.0.0
	 * @return Product|false
	 */
	public function get_product() {

		return wu_get_product($this->product_id);
	}

	/**
	 * Checks if this payment has line items.
	 *
	 * This is used to decide if we need to add the payment as a line-item of itself.
	 *
	 * @since 2.0.0
	 * @return boolean
	 */
	public function has_line_items(): bool {

		return ! empty($this->get_line_items());
	}

	/**
	 * Returns the line items for this payment.
	 *
	 * Line items are also \WP_Ultimo\Models\Payment objects, with the
	 * type 'line-item'.
	 *
	 * @since 2.0.0
	 * @return array
	 */
	public function get_line_items(): array {

		if (null === $this->line_items) {
			$line_items = (array) $this->get_meta('wu_line_items');

			$this->line_items = array_filter($line_items);
		}

		return (array) $this->line_items;
	}

	/**
	 * Set the line items of this payment.
	 *
	 * @since 2.0.0
	 *
	 * @param Line_Item[] $line_items THe line items.
	 * @return void
	 */
	public function set_line_items(array $line_items): void {

		$line_items = array_filter($line_items);

		$this->meta['wu_line_items'] = $line_items;

		$this->line_items = $line_items;
	}

	/**
	 * Add a new line item.
	 *
	 * @since 2.0.0
	 *
	 * @param Line_Item $line_item The line item.
	 * @return void
	 */
	public function add_line_item($line_item): void {

		$line_items = $this->get_line_items();

		if ( ! is_a($line_item, self::class)) {
			return;
		}

		$line_items[ $line_item->get_id() ] = $line_item;

		$this->set_line_items($line_items);

		krsort($this->line_items);
	}

	/**
	 * Returns an array containing the subtotal per tax rate.
	 *
	 * @since 2.0.0
	 * @return array $tax_rate => $tax_total.
	 */
	public function get_tax_breakthrough(): array {

		$line_items = $this->get_line_items();

		$tax_brackets = [];

		foreach ($line_items as $line_item) {
			$tax_bracket = $line_item->get_tax_rate();

			if ( ! $tax_bracket) {
				continue;
			}

			if (isset($tax_brackets[ $tax_bracket ])) {
				$tax_brackets[ $tax_bracket ] += $line_item->get_tax_total();

				continue;
			}

			$tax_brackets[ $tax_bracket ] = $line_item->get_tax_total();
		}

		return $tax_brackets;
	}

	/**
	 * Recalculate payment totals.
	 *
	 * @todo needs refactoring to use line_items.
	 *
	 * @since 2.0.0
	 * @return Payment
	 */
	public function recalculate_totals(): Payment {

		$line_items = $this->get_line_items();

		$tax_total = 0;

		$sub_total = 0;

		$refund_total = 0;

		$total = 0;

		foreach ($line_items as $line_item) {
			$line_item->recalculate_totals();

			$tax_total += $line_item->get_tax_total();

			$sub_total += $line_item->get_subtotal();

			$total += $line_item->get_total();

			if ($line_item->get_type() === 'refund') {
				$refund_total += $line_item->get_subtotal();
			}
		}

		$this->attributes(
			[
				'tax_total'    => $tax_total,
				'subtotal'     => $sub_total,
				'refund_total' => $refund_total,
				'total'        => $total,
			]
		);

		return $this;
	}

	/**
	 * Checks if this payment is payable still.
	 *
	 * @since 2.0.0
	 * @return boolean
	 */
	public function is_payable(): bool {

		$payable_statuses = apply_filters(
			'wu_payment_payable_statuses',
			[
				Payment_Status::PENDING,
				Payment_Status::FAILED,
			]
		);

		return $this->get_total() > 0 && in_array($this->get_status(), $payable_statuses, true);
	}

	/**
	 * Returns the link to pay for this payment.
	 *
	 * @since 2.0.0
	 * @return false|string Returns false if the payment is not in a payable status.
	 */
	public function get_payment_url() {

		if ( ! $this->is_payable()) {
			return false;
		}

		$slug = $this->get_hash();

		return add_query_arg(
			[
				'payment' => $slug,
			],
			wu_get_registration_url()
		);
	}

	/**
	 * Get iD of the product of this payment.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_product_id() {

		return $this->product_id;
	}

	/**
	 * Set iD of the product of this payment.
	 *
	 * @since 2.0.0
	 * @param int $product_id The ID of the product of this payment.
	 * @return void
	 */
	public function set_product_id($product_id): void {

		$this->product_id = $product_id;
	}

	/**
	 * Generates the Invoice URL.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_invoice_url() {

		$url_atts = [
			'action'    => 'invoice',
			'reference' => $this->get_hash(),
			'key'       => wp_create_nonce('see_invoice'),
		];

		return add_query_arg($url_atts, get_site_url(wu_get_main_site_id()));
	}

	/**
	 * Get iD of the payment on the gateway, if it exists.
	 *
	 * @since 2.0.0
	 * @return string
	 */
	public function get_gateway_payment_id() {

		return $this->gateway_payment_id;
	}

	/**
	 * Set iD of the payment on the gateway, if it exists.
	 *
	 * @since 2.0.0
	 * @param string $gateway_payment_id The ID of the payment on the gateway, if it exists.
	 * @return void
	 */
	public function set_gateway_payment_id($gateway_payment_id): void {

		$this->gateway_payment_id = $gateway_payment_id;
	}

	/**
	 * By default, we just use the to_array method, but you can rewrite this.
	 *
	 * @since 2.0.0
	 * @return array
	 */
	public function to_search_results() {

		$search_result = $this->to_array();

		$search_result['reference_code'] = $this->get_hash();

		$line_items = array_map(fn($line_item) => $line_item->to_array(), $this->get_line_items());

		$search_result['product_names'] = implode(', ', array_column($line_items, 'title'));

		return $search_result;
	}

	/**
	 * Get the total value in discounts.
	 *
	 * @since 2.0.0
	 * @return integer
	 */
	public function get_discount_total() {

		return (float) $this->discount_total;
	}

	/**
	 * Set the total value in discounts.
	 *
	 * @since 2.0.0
	 * @param integer $discount_total The total value of the discounts applied to this payment.
	 * @return void
	 */
	public function set_discount_total($discount_total): void {

		$this->discount_total = (float) $discount_total;
	}

	/**
	 * Get the invoice number actually saved on the payment.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_saved_invoice_number() {

		if (null === $this->invoice_number) {
			$this->invoice_number = $this->get_meta('wu_invoice_number', '');
		}

		return $this->invoice_number;
	}

	/**
	 * Get sequential invoice number assigned to this payment.
	 *
	 * @since 2.0.0
	 * @return int
	 */
	public function get_invoice_number() {

		if (wu_get_setting('invoice_numbering_scheme', 'reference_code') === 'reference_code') {
			return $this->get_hash();
		}

		$provisional = false;

		if (null === $this->invoice_number) {
			$this->invoice_number = $this->get_meta('wu_invoice_number');
		}

		if (false === $this->invoice_number) {
			$provisional = true;

			$this->invoice_number = wu_get_setting('next_invoice_number');
		}

		$prefix = wu_get_setting('invoice_prefix', '');

		$search = [
			'%YEAR%',
			'%MONTH%',
			'%DAY%',
			'%%YEAR%%',
			'%%MONTH%%',
			'%%DAY%%',
		];

		$replace = [
			gmdate('Y'),
			gmdate('m'),
			gmdate('d'),
			gmdate('Y'),
			gmdate('m'),
			gmdate('d'),
		];

		$prefix = str_replace($search, $replace, (string) $prefix);

		return sprintf('%s%s %s', $prefix, $this->invoice_number, $provisional ? __('(provisional)', 'wp-ultimo') : '');
	}

	/**
	 * Set sequential invoice number assigned to this payment.
	 *
	 * @since 2.0.0
	 * @param int $invoice_number Sequential invoice number assigned to this payment.
	 * @return void
	 */
	public function set_invoice_number($invoice_number): void {

		$this->meta['wu_invoice_number'] = $invoice_number;

		$this->invoice_number = $invoice_number;
	}

	/**
	 * Remove all non-recurring items from the payment.
	 *
	 * This is usually used when creating a new pending payment for
	 * a membership that needs to be manually renewed.
	 *
	 * @since 2.0.0
	 * @return self
	 */
	public function remove_non_recurring_items() {

		$line_items = $this->get_line_items();

		foreach ($line_items as $line_item_id => $line_item) {
			if ( ! $line_item->is_recurring()) {
				unset($line_items[ $line_item_id ]);
			}
		}

		$this->set_line_items($line_items);

		$this->recalculate_totals();

		return $this;
	}

	/**
	 * Get holds if we need to cancel the membership on refund..
	 *
	 * @since 2.0.0
	 * @return bool
	 */
	public function should_cancel_membership_on_refund() {

		if (null === $this->cancel_membership_on_refund) {
			$this->cancel_membership_on_refund = $this->get_meta('wu_cancel_membership_on_refund', false);
		}

		return $this->cancel_membership_on_refund;
	}

	/**
	 * Set holds if we need to cancel the membership on refund..
	 *
	 * @since 2.0.0
	 * @param bool $cancel_membership_on_refund Holds if we need to cancel the membership on refund.
	 * @return void
	 */
	public function set_cancel_membership_on_refund($cancel_membership_on_refund): void {

		$this->meta['wu_cancel_membership_on_refund'] = $cancel_membership_on_refund;

		$this->cancel_membership_on_refund = $cancel_membership_on_refund;
	}

	/**
	 * Handles a payment refund.
	 *
	 * This DOES NOT contact the gateway to refund a payment.
	 * It only updates the payment status to respond to a refund
	 * confirmation that originated from the gateway.
	 *
	 * An example of how that would work:
	 * 1. Admin issues a refund on the admin panel;
	 * 2. PayPal (for example), process the refund request
	 *    and sends back a IPN (webhook call) telling WP Multisite WaaS
	 *    that the refund was issued successfully;
	 * 3. The IPN handler listens for that event and calls this
	 *    to reflect the refund in the original WU payment.
	 *
	 * @since 2.0.0
	 *
	 * @param boolean      $amount The amount to refund.
	 * @param null|boolean $should_cancel_membership_on_refund If we should cancel a membership as well.
	 * @return void|bool
	 */
	public function refund($amount = false, $should_cancel_membership_on_refund = null) {
		/*
		 * If no amount was passed,
		 * refund the full amount.
		 */
		if (empty($amount)) {
			$amount = $this->get_total();
		}

		$amount = wu_to_float($amount);

		/*
		 * Do the same for the behavior regarding memberships.
		 */
		if (is_null($should_cancel_membership_on_refund)) {
			$should_cancel_membership_on_refund = $this->should_cancel_membership_on_refund();
		}

		/*
		 * First, deal with the status.
		 * The new status depends on the refund amount.
		 *
		 * If the amount is >= the total
		 * this is a total refund, otherwise,
		 * it is a partial refund.
		 */
		if ($amount >= $this->get_total()) {
			$title = __('Full Refund', 'wp-ultimo');

			$new_status = Payment_Status::REFUND;
		} else {
			$title = __('Partial Refund', 'wp-ultimo');

			$new_status = Payment_Status::PARTIAL_REFUND;
		}

		$time = current_time('timestamp'); // phpcs:ignore

		$formatted_value = date_i18n(get_option('date_format'), $time);

		// translators: %s is the date of processing.
		$description = sprintf(__('Processed on %s', 'wp-ultimo'), $formatted_value);

		$line_item_data = [
			'type'         => 'refund',
			'hash'         => uniqid(),
			'title'        => $title,
			'description'  => $description,
			'discountable' => false,
			'taxable'      => false,
			'unit_price'   => -$amount,
			'quantity'     => 1,
		];

		$refund_line_item = new Line_Item($line_item_data);

		$this->add_line_item($refund_line_item);

		$this->set_status($new_status);

		$this->recalculate_totals();

		$status = $this->save();

		if (is_wp_error($status)) {
			return $status;
		}

		/**
		 * Updating the payment went well.
		 * Let's deal with the membership, if needed.
		 */
		if ($should_cancel_membership_on_refund) {
			$membership = $this->get_membership();

			if ($membership) {
				$membership->cancel();
			}
		}

		return true;
	}

	/**
	 * Creates a copy of the given model adn resets it's id to a 'new' state.
	 *
	 * @since 2.0.0
	 * @return \WP_Ultimo\Model\Base_Model
	 */
	public function duplicate() {

		$line_items = $this->get_line_items();

		$new_payment = parent::duplicate();

		$new_payment->set_line_items($line_items);

		return $new_payment;
	}
}