<?php

defined('ABSPATH') or exit;

/**
 * The main controller for Extended Coupon Features for WooCommerce.
 */
class WJECF_Controller
{
    // Coupon message codes
    // NOTE: I use prefix 79 for this plugin; there's no guarantee that other plugins don't use the same values!
    public const E_WC_COUPON_MIN_MATCHING_SUBTOTAL_NOT_MET = 79100;
    public const E_WC_COUPON_MAX_MATCHING_SUBTOTAL_NOT_MET = 79101;
    public const E_WC_COUPON_MIN_MATCHING_QUANTITY_NOT_MET = 79102;
    public const E_WC_COUPON_MAX_MATCHING_QUANTITY_NOT_MET = 79103;
    public const E_WC_COUPON_SHIPPING_METHOD_NOT_MET = 79104;
    public const E_WC_COUPON_PAYMENT_METHOD_NOT_MET = 79105;
    public const E_WC_COUPON_NOT_FOR_THIS_USER = 79106;
    public const E_WC_COUPON_FIRST_PURCHASE_ONLY = 79107;
    public const E_WC_COUPON_SHIPPING_ZONE_NOT_MET = 79108;
    protected static $_instance;

    protected $plugins = [];

    private $options;
    private $_user_emails;
    private $flexible_shipping_rates;

    // Temporary storage
    private $coupon_multiplier_values = [];

    private $overwrite_coupon_message = []; // [ 'coupon_code' => [ msg_code => 'new_message' ] ]

    private $inhibit_overwrite = 0;

    // ============

    private $_session_data;

    /**
     * The debugger.
     *
     * @var null|bool|WJECF_Debug False if disabled, null if not yet loaded otherwise the WJECF_Debug instance
     */
    private $debugger;

    public function __construct()
    {
        $this->options = new WJECF_Options(
            'wjecf_options',
            [
                'db_version' => 0, // integer
                'debug_mode' => false, // true or false
                'disabled_plugins' => [], // e.g. [ 'WJECF_AutoCoupon' ]
                'autocoupon_allow_remove' => false,
            ],
        );
    }

    /**
     * Singleton Instance.
     *
     * @static
     *
     * @return Singleton Instance
     */
    public static function instance()
    {
        if (is_null(self::$_instance)) {
            self::$_instance = class_exists('WJECF_Pro_Controller') ? new WJECF_Pro_Controller() : new WJECF_Controller();
        }

        return self::$_instance;
    }

    public function start()
    {
        $this->init_plugins();
        $this->init_hooks();
    }

    public function init_hooks()
    {
        // Frontend hooks

        // assert_coupon_is_valid (which raises exception on invalid coupon) can only be used on WC 2.3.0 and up
        add_filter('woocommerce_coupon_is_valid', [$this, 'assert_coupon_is_valid'], 10, 3); // Since WC3.2 WC_Discounts is passed as a 3rd argument

        // Last check for coupons with restricted_emails (moved from WJECF_AutoCoupon since 2.5.6)
        add_action('woocommerce_checkout_update_order_review', [$this, 'fetch_billing_email'], 10); // AJAX One page checkout
        add_action('woocommerce_after_checkout_validation', [$this, 'fetch_billing_email'], 10); // Checkout posted

        // Overwrite coupon info message
        add_filter('woocommerce_coupon_message', [$this, 'filter_woocommerce_coupon_message'], 10, 3);
    }

    /**
     * Load a WJECF Plugin (class name).
     *
     * @param mixed $instance
     *
     * @return bool True if succeeded, otherwise false
     */
    public function add_plugin($instance)
    {
        if (is_string($instance)) {
            if (!class_exists($instance)) {
                $this->log('warning', 'Unknown plugin: '.$instance);

                return false; // Not found
            }
            $instance = new $instance();
        }

        $name = $instance->get_plugin_name();

        if (isset($this->plugins[$name])) {
            $this->log('warning', 'Plugin already loaded: '.$name);

            return false; // Already loaded
        }

        if (!$instance instanceof Abstract_WJECF_Plugin) {
            $this->log('warning', 'Plugin must be an instance of Abstract_WJECF_Plugin: '.$name);

            return false; // Invalid
        }

        $this->plugins[$name] = $instance;
        $this->log('debug', 'Loaded plugin: '.$name);

        return true;
    }

    /**
     * Get an array of all the plugins.
     *
     * @return array [ $key => $plugin ]
     */
    public function get_plugins()
    {
        return $this->plugins;
    }

    /**
     * Retrieves the WJECF Plugin.
     *
     * @param string $name Name of the plugin as yielded by $plugin->get_plugin_name() e.g. 'admin-settings'
     *
     * @return bool|object The plugin if found, otherwise returns false
     */
    public function get_plugin($name)
    {
        if (isset($this->plugins[$name])) {
            return $this->plugins[$name];
        }

        // Legacy support: E.g. allow 'WJECF_Autocoupon' instead of 'autocoupon'
        $adj_name = Abstract_WJECF_Plugin::sanitize_plugin_name($name);
        if (isset($this->plugins[$adj_name])) {
            $this->log('warning', sprintf('Plugin name %s has been changed to: %s', $name, $adj_name));

            return $this->plugins[$adj_name];
        }

        return false;
    }

    // OPTIONS

    public function get_options()
    {
        return $this->options->get();
    }

    public function get_option($key, $default = null)
    {
        return $this->options->get($key, $default);
    }

    public function set_option($key, $value)
    {
        $this->options->set($key, $value);
    }

    public function save_options()
    {
        if (!is_admin()) {
            $this->log('error', 'WJECF Options must only be saved from admin.');

            return;
        }
        $this->options->save();
    }

    public function sanitizer()
    {
        return WJECF_Sanitizer::instance();
    }

    /**
     * Same as WordPress add_action(), but prevents the callback to be recursively called.
     *
     * @param string   $tag
     * @param callable $function_to_add
     * @param int      $priority
     * @param int      $accepted_args
     */
    public function safe_add_action($tag, $function_to_add, $priority = 10, $accepted_args = 1)
    {
        $_recursion_limit = 5;
        WJECF_Action_Or_Filter::action($tag, $function_to_add, $priority, $accepted_args, $_recursion_limit);
    }

    // FRONTEND HOOKS

    /**
     * Extra validation rules for coupons.
     *
     * @param bool       $valid
     * @param WC_Coupon  $coupon
     * @param null|mixed $wc_discounts
     *
     * @return bool true if valid; False if not valid
     */
    public function coupon_is_valid($valid, $coupon, $wc_discounts = null)
    {
        try {
            return $this->assert_coupon_is_valid($valid, $coupon, $wc_discounts);
        } catch (Exception $e) {
            return false;
        }
    }

    /**
     * Extra validation rules for coupons. Throw an exception when not valid.
     *
     * @param bool       $valid
     * @param WC_Coupon  $coupon
     * @param null|mixed $wc_discounts
     *
     * @return bool True if valid; False if already invalid on function call. In any other case an Exception will be thrown.
     */
    public function assert_coupon_is_valid($valid, $coupon, $wc_discounts = null)
    {
        // Not valid? Then it will never validate, so get out of here
        if (!$valid) {
            return false;
        }

        if (is_null($wc_discounts)) {
            $wc_discounts = new WJECF_WC_Discounts(WC()->cart);
        }

        $coupon_code = $coupon->get_code();

        // Reset multiplier to initial value of null. The validate_* routines will call limit_multiplier() to set the multiplier value
        $this->coupon_multiplier_values[$coupon_code] = null;

        try {
            $this->validate_products_and($coupon, $wc_discounts);
            $this->validate_categories_and($coupon, $wc_discounts);
            $this->validate_min_max_quantity($coupon, $wc_discounts);
            $this->validate_min_max_subtotal($coupon, $wc_discounts);
            $this->validate_shipping_method($coupon);
            $this->validate_excluded_shipping_method($coupon);
            $this->validate_payment_method($coupon);
            $this->validate_customer($coupon, $wc_discounts);

            if ($this->is_pro()) {
                $this->validate_pro($coupon, $wc_discounts);
            }

            // We use our own filter (instead of woocommerce_coupon_is_valid) for easier compatibility management
            // e.g. WC prior to 2.3.0 can't handle Exceptions; while 2.3.0 and above require exceptions
            do_action('wjecf_assert_coupon_is_valid', $coupon, $wc_discounts);
        } catch (Exception $exception) {
            // Invalid coupon? Multiplier 0
            $this->coupon_multiplier_values[$coupon_code] = 0;

            throw $exception;
        }

        if ((float) $coupon->get_minimum_amount()) {
            $this->limit_multiplier($coupon, floor($this->get_subtotal($wc_discounts) / $coupon->get_minimum_amount()));
        }

        /*
         * Filters the (product-)multiplier value of the coupon.
         *
         * @since 2.6.3
         *
         * @param float|null   $multiplier       Current multiplier value (or null if no multiplier yet known)
         * @param WC_Coupon    $coupon           The coupon
         * @param WC_Discounts $wc_discounts     Discounts class containing the cart items (NOTE: Will be a WJECF_WC_Discounts for WC < 3.2.0)
         */
        $this->coupon_multiplier_values[$coupon_code] = apply_filters('wjecf_coupon_multiplier_value', $this->coupon_multiplier_values[$coupon_code], $coupon, $wc_discounts);

        return true;
    }

    /**
     * The amount of times the minimum spend / quantity / subtotal values are reached.
     *
     * @param mixed $coupon
     *
     * @return int 1 or more if coupon is valid, otherwise 0
     */
    public function get_coupon_multiplier_value($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $coupon_code = $coupon->get_code();

        // If coupon validation was not executed, the value is unknown
        if (!array_key_exists($coupon_code, $this->coupon_multiplier_values) && !$this->coupon_is_valid(true, $coupon)) {
            return 0;
            // Calling coupon_is_valid enforces $this->coupon_multiplier_values to be set; if the coupon is valid.
        }

        // null defaults to 1
        return is_null($this->coupon_multiplier_values[$coupon_code]) ? 1 : $this->coupon_multiplier_values[$coupon_code];
    }

    /**
     * (API FUNCTION)
     * The total amount of the products in the cart that match the coupon restrictions
     * since 2.2.2-b3.
     *
     * @param mixed      $coupon
     * @param null|mixed $wc_discounts
     */
    public function get_quantity_of_matching_products($coupon, $wc_discounts = null)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $items = WJECF_WC()->get_discount_items($wc_discounts);

        $qty = 0;
        foreach ($items as $item_key => $item) {
            if ($item->product && $this->coupon_is_valid_for_product($coupon, $item->product, $item->object)) {
                $item_quantity = $item->quantity;

                /**
                 * Filter to override the quantity of a single cart item or order item.
                 *
                 * @since 3.3.3
                 *
                 * @param int          $item_quantity The quantity
                 * @param WC_Coupon    $coupon        The coupon for which the quantity of matching products is currently being calculated
                 * @param object       $item          Contains these propeties: { object, product, quantity, price, key }
                 * @param WC_Discounts $wc_discounts  The WC_Discounts object that contains the full cart or order
                 */
                $item_quantity = apply_filters('wjecf_coupon_item_quantity', $item_quantity, $coupon, $item, $wc_discounts);

                $qty += $item_quantity;
            }
        }

        return $qty;
    }

    /**
     * (API FUNCTION)
     * The total value of the products in the cart that match the coupon restrictions
     * since 2.2.2-b3.
     *
     * @param mixed      $coupon
     * @param null|mixed $wc_discounts
     */
    public function get_subtotal_of_matching_products($coupon, $wc_discounts = null)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $items = WJECF_WC()->get_discount_items($wc_discounts);

        $subtotal_precise = 0;
        foreach ($items as $item_key => $item) {
            if ($item->product && $this->coupon_is_valid_for_product($coupon, $item->product, $item->object)) {
                $subtotal_precise += $item->price;
            }
        }

        return WJECF_WC()->wc_remove_number_precision($subtotal_precise);
    }

    /**
     * The total value of the products in the cart
     * since 2.2.2-b3.
     *
     * @param null|mixed $wc_discounts
     */
    public function get_subtotal($wc_discounts = null)
    {
        $items = WJECF_WC()->get_discount_items($wc_discounts);

        $subtotal_precise = 0;
        foreach ($items as $item_key => $item) {
            if ($item->product) {
                $subtotal_precise += $item->price;
            }
        }

        return WJECF_WC()->wc_remove_number_precision($subtotal_precise);
    }

    /**
     * (API FUNCTION)
     * Test if coupon is valid for the product
     * (this function is used to count the quantity of matching products).
     *
     * @param mixed $coupon
     * @param mixed $product
     * @param mixed $values
     */
    public function coupon_is_valid_for_product($coupon, $product, $values = [])
    {
        // Do not count the free products
        if (isset($values['_wjecf_free_product_coupon'])) {
            return false;
        }

        // Get the original coupon, without values overwritten by WJECF
        $duplicate_coupon = $this->get_original_coupon($coupon);

        // $coupon->is_valid_for_product() only works for fixed_product or percent_product discounts
        if (!$duplicate_coupon->is_type(WJECF_WC()->wc_get_product_coupon_types())) {
            $duplicate_coupon->set_discount_type('fixed_product');
        }

        return $duplicate_coupon->is_valid_for_product($product, $values);
    }

    // =====================

    /**
     * Get array of the selected shipping methods ids.
     *
     * @deprecated 3.2.0
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array id's of the shipping methods or an empty array
     */
    public function get_coupon_shipping_method_ids($coupon)
    {
        // Get all the coupon_shipping_ids that start with 'method:' and truncate the 'method:'-part
        $restrictions = $this->get_coupon_shipping_restrictions();
        $grouped = $this->group_shipping_restrictions($restrictions);

        return $grouped['method'] ?? '';
    }

    /**
     * Get array of the selected shipping zones, methods or instance ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array Id's or an empty array. Id's are prefixed by 'zone:', 'instance:' or 'method:'
     */
    public function get_coupon_shipping_restrictions($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $value = WJECF()->sanitizer()->sanitize($coupon->get_meta('_wjecf_shipping_restrictions'), 'clean');

        return is_array($value) ? $value : [];
    }

    /**
     * Get array of the excluded shipping zones, methods or instance ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array Id's or an empty array. Id's are prefixed by 'zone:', 'instance:' or 'method:'
     */
    public function get_coupon_excluded_shipping_restrictions($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $value = WJECF()->sanitizer()->sanitize($coupon->get_meta('_wjecf_excluded_shipping_restrictions'), 'clean');

        return is_array($value) ? $value : [];
    }

    /**
     * Get array of the selected payment method ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array id's of the payment methods or an empty array
     */
    public function get_coupon_payment_method_ids($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $v = $coupon->get_meta('_wjecf_payment_methods');

        return is_array($v) ? $v : [];
    }

    /**
     * Get array of the selected customer ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array id's of the customers (users) or an empty array
     */
    public function get_coupon_customer_ids($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $v = $coupon->get_meta('_wjecf_customer_ids');

        return WJECF()->sanitizer()->sanitize($v, 'int[]');
    }

    /**
     * Get array of the selected customer role ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array id's (string) of the customer roles or an empty array
     */
    public function get_coupon_customer_roles($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $v = $coupon->get_meta('_wjecf_customer_roles');

        return is_array($v) ? $v : [];
    }

    /**
     * Get array of the excluded customer role ids.
     *
     * @param string|WC_Coupon $coupon The coupon code or a WC_Coupon object
     *
     * @return array id's (string) of the excluded customer roles or an empty array
     */
    public function get_coupon_excluded_customer_roles($coupon)
    {
        $coupon = WJECF_WC()->get_coupon($coupon);
        $v = $coupon->get_meta('_wjecf_excluded_customer_roles');

        return is_array($v) ? $v : [];
    }

    // ===========================================================================
    // User identification
    // ===========================================================================

    /**
     * Get a list of the users' known email addresses.
     *
     * NOTE: Also called in AutoCoupon
     *
     * @since 2.5.6 (Moved from WJECF_AutoCoupon)
     *
     * @return array The user's known email addresses
     */
    public function get_user_emails()
    {
        if (!is_array($this->_user_emails)) {
            $this->_user_emails = [];
            // Email of the logged in user
            if (is_user_logged_in()) {
                $current_user = wp_get_current_user();
                $this->_user_emails[] = $current_user->user_email;
            }
        }
        $user_emails = $this->_user_emails;

        $billing_email = $this->get_session('billing_email', '');
        if (is_email($billing_email)) {
            $user_emails[] = $billing_email;
        }

        $user_emails = array_map('strtolower', $user_emails);
        $user_emails = array_map('sanitize_email', $user_emails);
        $user_emails = array_filter($user_emails, 'is_email');

        return array_unique($user_emails);
    }

    /**
     * Called on action: woocommerce_checkout_update_order_review.
     *
     * Collects billing email address from the checkout-form
     *
     * @param mixed $post_data
     */
    public function fetch_billing_email($post_data)
    {
        // post_data can be an array, or a query=string&like=this
        if (!is_array($post_data)) {
            parse_str($post_data, $posted);
        } else {
            $posted = $post_data;
        }

        if (isset($posted['billing_email'])) {
            // $this->log('debug', 'billing:' . $posted['billing_email']);
            WJECF()->set_session('billing_email', $posted['billing_email']);
        }
    }

    public function is_pro()
    {
        return $this instanceof WJECF_Pro_Controller;
    }

    // ===========================================================================
    // START - OVERWRITE COUPON SUCCESS MESSAGE
    // ===========================================================================

    /**
     * 2.3.4
     * If a 'Coupon applied' message is displayed by WooCommerce, replace it by another message (or no message).
     *
     * @param WC_Coupon $coupon      The coupon to replace the message for
     * @param string    $new_message The new message. Set to empty string if no message must be displayed
     */
    public function start_overwrite_success_message($coupon, $new_message = '')
    {
        $this->overwrite_coupon_message[$coupon->get_code()] = [WC_Coupon::WC_COUPON_SUCCESS => $new_message];
    }

    /**
     * 2.3.4
     * Stop overwriting messages.
     */
    public function stop_overwrite_success_message()
    {
        $this->overwrite_coupon_message = [];
    }

    public function filter_woocommerce_coupon_message($msg, $msg_code, $coupon)
    {
        if (isset($this->overwrite_coupon_message[$coupon->get_code()][$msg_code])) {
            $msg = $this->overwrite_coupon_message[$coupon->get_code()][$msg_code];
        }

        return $msg;
    }

    // ===========================================================================
    // END - OVERWRITE COUPON SUCCESS MESSAGE
    // ===========================================================================

    /**
     * Return an array of WC_Coupons with coupons that shouldn't cause individual use conflicts.
     *
     * @param WC_Coupon[] $coupons              The coupons
     * @param string[]    $applied_coupon_codes Coupon codes that are considered to be in the cart. If null WC()->cart->get_applied_coupons() will be used.
     *
     * @return WC_Coupon[]
     */
    public function coupon_combination_filter($coupons, $applied_coupon_codes = null)
    {
        $filtered_coupons = [];

        // Contains coupon codes that are already in cart or pending in the filtered-array
        if (null === $applied_coupon_codes) {
            $applied_coupon_codes = WC()->cart->get_applied_coupons();
        }

        foreach ($coupons as $the_coupon) {
            if ($the_coupon->get_individual_use() && !in_array($the_coupon->get_code(), $applied_coupon_codes)) {
                // Only allow a new automatic individual use coupon if it doesn't remove coupons from the cart.
                $coupons_to_keep = apply_filters('woocommerce_apply_individual_use_coupon', [], $the_coupon, $applied_coupon_codes);
                if (count($applied_coupon_codes) != count(array_intersect($applied_coupon_codes, $coupons_to_keep))) {
                    continue; // skip coupon.
                }
            }

            // Check to see if an individual use coupon is already in the cart.
            foreach ($applied_coupon_codes as $code) {
                if ($code === $the_coupon->get_code()) {
                    // Dont compare the coupon with itself
                    continue;
                }
                $coupon = new WC_Coupon($code);
                if ($coupon->get_individual_use() && false === apply_filters('woocommerce_apply_with_individual_use_coupon', false, $the_coupon, $coupon, $applied_coupon_codes)) {
                    continue 2; // skip coupon.
                }
            }

            /**
             * Filter to disallow certain coupon combinations to be auto-applied together.
             *
             * @since 3.0.0
             *
             * @param WC_Coupon $the_coupon           Coupon to apply
             * @param string[]  $applied_coupon_codes Codes of the coupons already in the cart
             */
            if (!apply_filters('wjecf_apply_with_other_coupons', true, $the_coupon, $applied_coupon_codes)) {
                continue; // skip coupon.
            }

            $applied_coupon_codes[] = $the_coupon->get_code();
            $filtered_coupons[] = $the_coupon;
        }

        return $filtered_coupons;
    }

    /**
     * @since 2.4.4
     *
     * Get a coupon, but inhibit the woocommerce_coupon_loaded to overwrite values.
     *
     * @param string|WC_Coupon $coupon_code The coupon code or a WC_Coupon object
     *
     * @return WC_Coupon The coupon object
     */
    public function get_original_coupon($coupon_code)
    {
        // Prevent returning the same instance
        if ($coupon_code instanceof WC_Coupon) {
            $coupon_code = $coupon_code->get_code();
        }
        ++$this->inhibit_overwrite;
        $coupon = WJECF_WC()->get_coupon($coupon_code);
        --$this->inhibit_overwrite;

        return $coupon;
    }

    /**
     * @since 2.4.4
     *
     * May coupon values be overwritten by this plugin upon load?
     *
     * @return bool
     */
    public function allow_overwrite_coupon_values()
    {
        return (0 == $this->inhibit_overwrite) && $this->is_request('frontend');
    }

    /**
     * Read something from the session.
     *
     * If key is omitted; all the session data will be returned as an array
     *
     * @param string $key     The key for identification
     * @param any    $default The default value (Default: false)
     *
     * @return The saved value if found, otherwise the default value
     */
    public function get_session($key = null, $default = false)
    {
        if (!isset($this->_session_data)) {
            if (!isset(WC()->session)) {
                $this->log('error', 'Trying to access WC()->session while it was not yet initialized.');

                return null;
            }
            $this->_session_data = WC()->session->get('_wjecf_session_data', []);
        }

        if (!isset($key)) {
            return $this->_session_data;
        }
        if (!isset($this->_session_data[$key])) {
            return $default;
        }

        return $this->_session_data[$key];
    }

    /**
     * Save something in the session.
     *
     * @param string   $key   The key for identification
     * @param anything $value The value to store. Use 'null' to remove the value
     */
    public function set_session($key, $value)
    {
        if (!isset($this->_session_data)) {
            if (!isset(WC()->session)) {
                $this->log('error', 'Trying to access WC()->session while it was not yet initialized.');

                return null;
            }
            $this->_session_data = WC()->session->get('_wjecf_session_data', []);
        }
        if (is_null($value)) {
            unset($this->_session_data[$key]);
        } else {
            $this->_session_data[$key] = $value;
        }

        WC()->session->set('_wjecf_session_data', $this->_session_data);
    }

    /**
     * (Copied from class-woocommerce.php) What type of request is this?
     *
     * @since 2.6.2
     *
     * @param string $type admin, ajax, cron or frontend
     *
     * @return bool
     */
    public function is_request($type)
    {
        switch ($type) {
            case 'admin':
                return is_admin();

            case 'ajax':
                return defined('DOING_AJAX');

            case 'cron':
                return defined('DOING_CRON');

            case 'frontend':
                return (!is_admin() || defined('DOING_AJAX')) && !defined('DOING_CRON');
        }
    }

    // ========================
    // INFO ABOUT WJECF PLUGIN
    // ========================

    /**
     * Filename of this plugin including the containing directory.
     *
     * @return string
     */
    public function plugin_file()
    {
        $filename = $this->is_pro() ? 'woocommerce-jos-autocoupon-pro.php' : 'woocommerce-jos-autocoupon.php';

        return trailingslashit(basename(dirname(dirname(__FILE__)))).$filename;
    }

    public function plugin_basename()
    {
        return plugin_basename($this->plugin_file());
    }

    /**
     * url to the base directory of this plugin (wp-content/woocommerce-jos-autocoupon/) with trailing slash.
     *
     * @param mixed $suffix
     *
     * @return string
     */
    public function plugin_url($suffix = '')
    {
        return plugins_url('/', dirname(__FILE__)).$suffix;
    }

    public function plugin_version()
    {
        return WJECF_VERSION;
    }

    // ========================
    // LOGGING
    // ========================

    /**
     * Log a message for debugging.
     *
     * If debug_mode is false; messages with level 'debug' will be ignored.
     *
     * @param string     $level          The level of the message. e.g. 'debug' or 'warning'
     * @param int        $skip_backtrace Defaults to 0, amount of items to skip in backtrace to fetch class and method name
     * @param null|mixed $message
     */
    public function log($level, $message = null, $skip_backtrace = 0)
    {
        if (false === $this->debugger) {
            return; // Debugger is disabled.
        }

        if (!isset($this->debugger)) {
            if (in_array('debug', $this->get_option('disabled_plugins'))) {
                $this->debugger = false;

                return;
            }

            $debugger = $this->get_plugin('debug');
            if (!$debugger) {
                // Fallback to error_log if the logger is not yet loaded.
                error_log(sprintf('WJECF: %s: %s', $level, $message));

                return;
            }

            $this->debugger = $debugger;
        }

        $this->debugger->log($level, $message, $skip_backtrace + 1);
    }

    protected function init_plugins()
    {
        /*
         * Fires before the WJECF plugins are initialised.
         *
         * Perfect hook for themes or plugins to load custom WJECF plugins.
         *
         * @since 2.3.7
         */
        do_action('wjecf_init_plugins');

        // Start the plugins
        foreach (WJECF()->get_plugins() as $name => $plugin) {
            if ($plugin->plugin_is_enabled()) {
                try {
                    $plugin->assert_dependencies();
                } catch (Exception $ex) {
                    if (is_admin() && WJECF_Admin()) {
                        // translators: 1: plugin-name 2: exception message
                        $msg = sprintf(__('Failed loading plugin %1$s: %2$s.', 'woocommerce-jos-autocoupon'), $name, $ex->getMessage());
                        WJECF_Admin()->enqueue_notice($msg);
                    }

                    continue;
                }

                $plugin->init_hook();
                if (is_admin()) {
                    $plugin->init_admin_hook();
                }
            }
        }
    }

    /**
     * Validate 'products AND'. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon    $coupon
     * @param WC_Discounts $wc_discounts
     */
    private function validate_products_and($coupon, $wc_discounts)
    {
        // Test if ALL products are in the cart (if AND-operator selected instead of the default OR)
        $products_and = 'yes' == $coupon->get_meta('_wjecf_products_and');
        if (!$products_and || sizeof($coupon->get_product_ids()) <= 1) { // We use > 1, because if size == 1, 'AND' makes no difference
            return;
        }

        // Get array of all cart product and variation ids
        $item_ids = [];
        foreach ($wc_discounts->get_items() as $item_key => $item) {
            if (!empty($item->product)) {
                $item_ids[] = $item->product->get_id();
                if ($item->product->is_type('variation')) {
                    $item_ids[] = $item->product->get_parent_id();
                }
            }
        }
        // Filter used by WJECF_WPML hook
        $item_ids = apply_filters('wjecf_get_product_ids', array_unique($item_ids));

        // check if every single product is in the cart
        foreach (apply_filters('wjecf_get_product_ids', $coupon->get_product_ids()) as $product_id) {
            if (!in_array($product_id, $item_ids)) {
                throw new Exception(WC_Coupon::E_WC_COUPON_NOT_APPLICABLE);
            }
        }
    }

    /**
     * Validate 'categories AND'. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon    $coupon
     * @param WC_Discounts $wc_discounts
     */
    private function validate_categories_and($coupon, $wc_discounts)
    {
        // Test if products form ALL categories are in the cart (if AND-operator selected instead of the default OR)
        $categories_and = 'yes' == $coupon->get_meta('_wjecf_categories_and');
        if (!$categories_and || sizeof($coupon->get_product_categories()) <= 1) { // We use > 1, because if size == 1, 'AND' makes no difference
            return;
        }

        // Get array of all cart product and variation ids
        $product_cats = [];

        foreach ($wc_discounts->get_items() as $item_key => $item) {
            if (!$item->product) {
                continue;
            }

            $product_id = $item->product->get_id();
            if ('product_variation' == get_post_type($product_id)) {
                $product_id = $item->product->get_parent_id();
            }
            $product_cats = array_merge($product_cats, wp_get_post_terms($product_id, 'product_cat', ['fields' => 'ids']));
        }

        // Filter used by WJECF_WPML hook
        $product_cats = apply_filters('wjecf_get_product_cat_ids', $product_cats);
        // check if every single category is in the cart
        foreach (apply_filters('wjecf_get_product_cat_ids', $coupon->get_product_categories()) as $cat_id) {
            if (!in_array($cat_id, $product_cats)) {
                $this->log('debug', $cat_id.' is not in '.implode(',', $product_cats));

                throw new Exception(WC_Coupon::E_WC_COUPON_NOT_APPLICABLE);
            }
        }
    }

    /**
     * Validate min/max quantity of matching products. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon    $coupon
     * @param WC_Discounts $wc_discounts
     */
    private function validate_min_max_quantity($coupon, $wc_discounts)
    {
        // Test min/max quantity of matching products
        //
        // For all items in the cart:
        //  If coupon contains both a product AND category inclusion filter: the item is counted if it matches either one of them
        //  If coupon contains either a product OR category exclusion filter: the item will NOT be counted if it matches either one of them
        //  If sale items are excluded by the coupon: the item will NOT be counted if it is a sale item
        //  If no filter exist, all items will be counted

        // Validate quantity
        $min_matching_product_qty = intval($coupon->get_meta('_wjecf_min_matching_product_qty'));
        $max_matching_product_qty = intval($coupon->get_meta('_wjecf_max_matching_product_qty'));
        if ($min_matching_product_qty <= 0 && 0 == $max_matching_product_qty) {
            return;
        }

        // Count the products
        $qty = $this->get_quantity_of_matching_products($coupon, $wc_discounts);
        // $this->log( 'debug', 'Quantity of matching products: ' . $qty );
        if ($min_matching_product_qty > 0 && $qty < $min_matching_product_qty) {
            throw new Exception(
                // translators: 1: minimum quantity
                sprintf(__('The minimum quantity of matching products for this coupon is %s.', 'woocommerce-jos-autocoupon'), $min_matching_product_qty),
                self::E_WC_COUPON_MIN_MATCHING_QUANTITY_NOT_MET,
            );
        }
        if ($max_matching_product_qty > 0 && $qty > $max_matching_product_qty) {
            throw new Exception(
                // translators: 1: maximum quantity
                sprintf(__('The maximum quantity of matching products for this coupon is %s.', 'woocommerce-jos-autocoupon'), $max_matching_product_qty),
                self::E_WC_COUPON_MAX_MATCHING_QUANTITY_NOT_MET,
            );
        }

        if ($min_matching_product_qty > 0) {
            $this->limit_multiplier($coupon, floor($qty / $min_matching_product_qty));
        }
    }

    /**
     * Validate min/max subtotal of matching products. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon    $coupon
     * @param WC_Discounts $wc_discounts
     */
    private function validate_min_max_subtotal($coupon, $wc_discounts)
    {
        // Validate subtotal (2.2.2)
        $min_matching_product_subtotal = floatval($coupon->get_meta('_wjecf_min_matching_product_subtotal'));
        $max_matching_product_subtotal = floatval($coupon->get_meta('_wjecf_max_matching_product_subtotal'));

        if ($min_matching_product_subtotal <= 0.0 && 0.0 === $max_matching_product_subtotal) {
            return;
        }

        $subtotal = $this->get_subtotal_of_matching_products($coupon, $wc_discounts);

        if ($min_matching_product_subtotal > 0.0) {
            if ($subtotal < $min_matching_product_subtotal) {
                throw new Exception(
                    // translators: 1: minimum subtotal
                    sprintf(__('The minimum subtotal of the matching products for this coupon is %s.', 'woocommerce-jos-autocoupon'), wc_price($min_matching_product_subtotal)),
                    self::E_WC_COUPON_MIN_MATCHING_SUBTOTAL_NOT_MET,
                );
            }
            $this->limit_multiplier($coupon, floor($subtotal / $min_matching_product_subtotal));
        }
        if ($max_matching_product_subtotal > 0.0 && $subtotal > $max_matching_product_subtotal) {
            throw new Exception(
                // translators: 1: maximum subtotal
                sprintf(__('The maximum subtotal of the matching products for this coupon is %s.', 'woocommerce-jos-autocoupon'), wc_price($max_matching_product_subtotal)),
                self::E_WC_COUPON_MAX_MATCHING_SUBTOTAL_NOT_MET,
            );
        }
    }

    /**
     * Validate shipping method. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon $coupon
     */
    private function validate_shipping_method($coupon)
    {
        $restrictions = $this->get_coupon_shipping_restrictions($coupon);
        if (empty($restrictions)) {
            return;
        }

        $grouped = $this->group_shipping_restrictions($restrictions);

        foreach ($grouped as $group => $ids) {
            switch ($group) {
                case 'zone':
                    if ($this->matches_shipping_zone($ids)) {
                        return;
                    }

                    break;

                case 'method':
                    if ($this->matches_shipping_method($ids)) {
                        return;
                    }

                    break;

                case 'instance':
                    if ($this->matches_shipping_instance($ids)) {
                        return;
                    }

                    break;

                default:
                    $this->log('warning', "Unknown shipping restriction type '{$group}'");
            }
        }

        if (array_key_exists('zone', $grouped) && !array_intersect(array_keys($grouped), ['method', 'instance'])) {
            throw new Exception(
                __('The coupon is not valid for your region.', 'woocommerce-jos-autocoupon'),
                self::E_WC_COUPON_SHIPPING_ZONE_NOT_MET,
            );
        }

        throw new Exception(
            __('The coupon is not valid for the currently selected shipping method.', 'woocommerce-jos-autocoupon'),
            self::E_WC_COUPON_SHIPPING_METHOD_NOT_MET,
        );
    }

    /**
     * Validate shipping method. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon $coupon
     */
    private function validate_excluded_shipping_method($coupon)
    {
        $restrictions = $this->get_coupon_excluded_shipping_restrictions($coupon);
        if (empty($restrictions)) {
            return;
        }

        $grouped = $this->group_shipping_restrictions($restrictions);

        foreach ($grouped as $group => $ids) {
            switch ($group) {
                case 'zone':
                    if ($this->matches_shipping_zone($ids)) {
                        throw new Exception(
                            __('The coupon is not valid for your region.', 'woocommerce-jos-autocoupon'),
                            self::E_WC_COUPON_SHIPPING_ZONE_NOT_MET,
                        );
                    }

                    break;

                case 'method':
                    if ($this->matches_shipping_method($ids)) {
                        throw new Exception(
                            __('The coupon is not valid for the currently selected shipping method.', 'woocommerce-jos-autocoupon'),
                            self::E_WC_COUPON_SHIPPING_METHOD_NOT_MET,
                        );
                    }

                    break;

                case 'instance':
                    if ($this->matches_shipping_instance($ids)) {
                        throw new Exception(
                            __('The coupon is not valid for the currently selected shipping method.', 'woocommerce-jos-autocoupon'),
                            self::E_WC_COUPON_SHIPPING_METHOD_NOT_MET,
                        );
                    }

                    break;

                default:
                    $this->log('warning', "Unknown shipping restriction type '{$group}'");
            }
        }
    }

    private function matches_shipping_method($coupon_shipping_methods)
    {
        return !empty(array_intersect(wc_get_chosen_shipping_method_ids(), $coupon_shipping_methods));
    }

    private function matches_shipping_zone($coupon_shipping_zones)
    {
        $packages = WC()->cart->get_shipping_packages();
        foreach ($packages as $package) {
            $zone_id = WC_Shipping_Zones::get_zone_matching_package($package)->get_id();

            if (in_array($zone_id, $coupon_shipping_zones)) {
                return true;
            }
        }

        return false;
    }

    private function matches_shipping_instance($coupon_shipping_instance_ids)
    {
        $instances = WC()->session->get('chosen_shipping_methods', []); // e.g. [ 'local_pickup:1', 'flat_rate:2' ]
        if (!is_array($instances)) {
            return false;
        }
        foreach ($instances as $instance) {
            // "Flexible Shipping" by WP Desk compatibility
            if (0 === strpos($instance, 'flexible_shipping_')) {
                $instance = $this->get_flexible_shipping_instance($instance);
            }

            // Examples where 11 is the instance id:
            //  Core shipping methods use format 'shiping_method:11'
            //  "Table Rate Shipping for WooCommerce" by Border Elements uses format 'betrs_shipping:11-1'
            //  "Table Rate Shipping" by WooCommerce uses format 'table_rate:11:4'.
            if (!preg_match('/:(\d+)/', $instance, $matches)) {
                continue;
            }
            $instance_id = $matches[1];
            if (in_array($instance_id, $coupon_shipping_instance_ids)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Flexible shipping plugin stores the shipping method as 'flexible_shipping_?_?'.
     * By looking up the rates from ['flexible_shipping'] we can get the instance id.
     *
     * Returns 'flexible_shipping_?_?:?' if found (where the latter question mark is the instance id)
     * Otherwise returns the original $instance_name
     *
     * @param string $instance_name
     *
     * @return string $instance_name with the instance id added
     */
    private function get_flexible_shipping_instance($instance_name)
    {
        if (null === $this->flexible_shipping_rates) {
            $all_shipping_methods = WC()->shipping()->load_shipping_methods();
            if (isset($all_shipping_methods['flexible_shipping'])) {
                $this->flexible_shipping_rates = $all_shipping_methods['flexible_shipping']->get_all_rates();
            } else {
                $this->flexible_shipping_rates = false;
            }
        }

        if (isset($this->flexible_shipping_rates[$instance_name]['woocommerce_method_instance_id'])) {
            return $instance_name.':'.$this->flexible_shipping_rates[$instance_name]['woocommerce_method_instance_id'];
        }

        return $instance_name;
    }

    /**
     * Validate payment method. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon $coupon
     */
    private function validate_payment_method($coupon)
    {
        $payment_method_ids = $this->get_coupon_payment_method_ids($coupon);
        if (empty($payment_method_ids)) {
            return;
        }

        $chosen_payment_method = WC()->session->get('chosen_payment_method');
        if (!in_array($chosen_payment_method, $payment_method_ids)) {
            throw new Exception(
                __('The coupon is not valid for the currently selected payment method.', 'woocommerce-jos-autocoupon'),
                self::E_WC_COUPON_PAYMENT_METHOD_NOT_MET,
            );
        }
    }

    /**
     * Validate if the coupon applies to the customer. An Exception will be thrown if the coupon does not apply.
     *
     * @param WC_Coupon $coupon
     * @param mixed     $wc_discounts
     */
    private function validate_customer($coupon, $wc_discounts)
    {
        // NOTE: If both customer id and role restrictions are provided, the coupon matches if either the id or the role matches
        $coupon_customer_ids = $this->get_coupon_customer_ids($coupon);
        $coupon_customer_roles = $this->get_coupon_customer_roles($coupon);
        if (!empty($coupon_customer_ids) || !empty($coupon_customer_roles)) {
            $user = WJECF_WC()->get_user($wc_discounts);
            // If both fail we invalidate. Otherwise it's ok
            if (!$user || (!in_array($user->ID, $coupon_customer_ids) && !array_intersect($user->roles, $coupon_customer_roles))) {
                throw new Exception(
                    // translators: 1: coupon code
                    sprintf(__('Sorry, it seems the coupon "%s" is not yours.', 'woocommerce-jos-autocoupon'), $coupon->get_code()),
                    self::E_WC_COUPON_NOT_FOR_THIS_USER,
                );
            }
        }

        // ============================
        // Test excluded user roles
        $coupon_excluded_customer_roles = $this->get_coupon_excluded_customer_roles($coupon);
        if (!empty($coupon_excluded_customer_roles)) {
            $user = WJECF_WC()->get_user($wc_discounts);
            if ($user && array_intersect($user->roles, $coupon_excluded_customer_roles)) {
                throw new Exception(
                    // translators: 1: coupon code
                    sprintf(__('Sorry, it seems the coupon "%s" is not yours.', 'woocommerce-jos-autocoupon'), $coupon->get_code()),
                    self::E_WC_COUPON_NOT_FOR_THIS_USER,
                );
            }
        }
    }

    /**
     * Limit the multiplier value for the coupon (i.e. if $multiplier_value < current multiplier value; overwrite current multiplier value).
     *
     * @param WC_Coupon $coupon
     * @param mixed     $multiplier_value
     */
    private function limit_multiplier($coupon, $multiplier_value)
    {
        $coupon_code = $coupon->get_code();
        if (isset($this->coupon_multiplier_values[$coupon_code])) {
            $this->coupon_multiplier_values[$coupon_code] = min($this->coupon_multiplier_values[$coupon_code], $multiplier_value);
        } else {
            $this->coupon_multiplier_values[$coupon_code] = $multiplier_value;
        }
    }

    /**
     * Groups the restrictions by the part before the ':' of every restriction.
     *
     * @param string[] $restrictions E.g. [ 'zone:1', 'method:flat_rate', 'method:local_pickup' ]
     *
     * @return array Grouped array, e.g. [ 'zone' : ['1'], 'method' : ['flat_rate', 'local_pickup'] ]
     */
    private function group_shipping_restrictions($restrictions)
    {
        $grouped = [];
        foreach ($restrictions as $restriction) {
            $split = explode(':', $restriction, 2);
            if (2 === count($split)) {
                $grouped[$split[0]][] = $split[1];
            }
        }

        return $grouped;
    }
}
