<?php
namespace ACFWF\Models;

use ACFWF\Abstracts\Abstract_Main_Plugin_Class;
use ACFWF\Helpers\Helper_Functions;
use ACFWF\Helpers\Plugin_Constants;
use ACFWF\Interfaces\Initializable_Interface;
use ACFWF\Interfaces\Model_Interface;
use ACFWF\Models\Objects\Advanced_Coupon;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Model that houses the Editor_Blocks module logic.
 * Public Model.
 *
 * @since 1.1
 */
class Editor_Blocks implements Model_Interface, Initializable_Interface {
    /*
    |--------------------------------------------------------------------------
    | Class Properties
    |--------------------------------------------------------------------------
     */

    /**
     * Property that holds the single main instance of URL_Coupon.
     *
     * @since 1.1
     * @access private
     * @var Cart_Conditions
     */
    private static $_instance;

    /**
     * Model that houses all the plugin constants.
     *
     * @since 1.1
     * @access private
     * @var Plugin_Constants
     */
    private $_constants;

    /**
     * Property that houses all the helper functions of the plugin.
     *
     * @since 1.1
     * @access private
     * @var Helper_Functions
     */
    private $_helper_functions;

    /*
    |--------------------------------------------------------------------------
    | Class Methods
    |--------------------------------------------------------------------------
     */

    /**
     * Class constructor.
     *
     * @since 1.1
     * @access public
     *
     * @param Abstract_Main_Plugin_Class $main_plugin      Main plugin object.
     * @param Plugin_Constants           $constants        Plugin constants object.
     * @param Helper_Functions           $helper_functions Helper functions object.
     */
    public function __construct( Abstract_Main_Plugin_Class $main_plugin, Plugin_Constants $constants, Helper_Functions $helper_functions ) {
        $this->_constants        = $constants;
        $this->_helper_functions = $helper_functions;

        $main_plugin->add_to_all_plugin_models( $this );
        $main_plugin->add_to_public_models( $this );
    }

    /**
     * Ensure that only one instance of this class is loaded or can be loaded ( Singleton Pattern ).
     *
     * @since 1.1
     * @access public
     *
     * @param Abstract_Main_Plugin_Class $main_plugin      Main plugin object.
     * @param Plugin_Constants           $constants        Plugin constants object.
     * @param Helper_Functions           $helper_functions Helper functions object.
     * @return Cart_Conditions
     */
    public static function get_instance( Abstract_Main_Plugin_Class $main_plugin, Plugin_Constants $constants, Helper_Functions $helper_functions ) {
        if ( ! self::$_instance instanceof self ) {
            self::$_instance = new self( $main_plugin, $constants, $helper_functions );
        }

        return self::$_instance;
    }

    /**
     * Register custom gutenberg blocks.
     *
     * @since 3.1
     * @access private
     */
    private function _register_blocks() {
        wp_register_style(
            'acfw-blocks-frontend',
            $this->_constants->CSS_ROOT_URL . 'acfw-blocks-frontend.css',
            array(),
            Plugin_Constants::VERSION,
            'all'
        );

        // coupons by category block.
        \register_block_type(
            'acfw/coupons-category',
            array(
                'description'     => __( 'Display a grid of advanced coupons from your selected categories.', 'advanced-coupons-for-woocommerce-free' ),
                'render_callback' => array( $this, 'render_coupons_by_category_block' ),
                'attributes'      => $this->_get_coupons_by_category_atts(),
                'style'           => 'acfw-blocks-frontend',
            )
        );

        // single coupon block.
        \register_block_type(
            'acfw/single-coupon',
            array(
                'description'     => __( "Display a single advanced coupon that you've selected.", 'advanced-coupons-for-woocommerce-free' ),
                'render_callback' => array( $this, 'render_single_coupon_block' ),
                'attributes'      => $this->_get_single_coupon_atts(),
                'style'           => 'acfw-blocks-frontend',
            )
        );

        // coupons by customer block.
        \register_block_type(
            'acfw/coupons-customer',
            array(
                'description'     => __( 'Display a grid of coupons and/or virtual coupons that is assigned to a customer.', 'advanced-coupons-for-woocommerce-free' ),
                'render_callback' => array( $this, 'render_coupons_by_customer_block' ),
                'attributes'      => $this->_get_coupons_by_customer_atts(),
                'style'           => 'acfw-blocks-frontend',
            )
        );
    }

    /**
     * Register advanced coupons block category in Gutenberg.
     *
     * @since 3.1
     * @access public
     *
     * @param array $categories List of gutenberg block categories.
     * @return array Filtered list of gutenberg block categories.
     */
    public function register_acfw_block_category( $categories ) {
        $cat_slugs = wp_list_pluck( $categories, 'slug' );
        if ( ! in_array( 'advancedcoupons', $cat_slugs, true ) ) {
            $categories[] = array(
                'slug'  => 'advancedcoupons',
                'title' => 'Advanced Coupons',
                'icon'  => null,
            );
        }

        return $categories;
    }

    /**
     * Render the coupons by category block on frontend.
     *
     * @since 3.1
     * @access public
     *
     * @param array $attributes Block attributes.
     * @return string HTML markup of block.
     */
    public function render_coupons_by_category_block( $attributes ) {
        // don't proceed if there are no categories set.
        if ( ! is_array( $attributes ) || ! isset( $attributes['categories'] ) ) {
            return '';
        }

        $defaults   = $this->_get_coupons_by_category_atts( true );
        $attributes = $this->_sanitize_parse_atts( $attributes, $defaults );

        // build query object.
        $args  = $this->_append_sort_arguments_for_query(
            array(
                'post_type'           => 'shop_coupon',
                'post_status'         => 'publish',
                'posts_per_page'      => $attributes['count'],
                'fields'              => 'ids',
                'ignore_sticky_posts' => true,
                'tax_query'           => array(
                    array(
                        'taxonomy' => 'shop_coupon_cat',
                        'field'    => 'term_id',
                        'terms'    => $attributes['categories'],
                    ),
                ),
            ),
            $attributes['order_by']
        );
        $query = new \WP_Query( $args );

        // don't proceed if there are no coupons detected in loop.
        if ( empty( $query->posts ) ) {
            return '';
        }

        // map list of IDs to list of advanced coupon objects.
        $coupons = $this->_get_coupons_from_queried_ids( $query->posts );

        // Filter out expired coupons.
        $coupons = array_filter(
            $coupons,
            function ( $coupon ) use ( $attributes ) {
            $expiry_date = $coupon->get_date_expires( 'edit' );
                if ( ! $expiry_date ) {
                    return true; // Keep coupons with no expiry.
                }

                if ( ! ( isset( $attributes['contentVisibility'] )
                        && isset( $attributes['contentVisibility']->expired_coupons )
                        && $attributes['contentVisibility']->expired_coupons ) ) {
                    return true; // Keep coupons with no expiry.
                }

                $current_time = $this->_helper_functions->get_datetime_with_site_timezone( current_time( 'mysql' ) );
                return $expiry_date->getTimestamp() > $current_time->getTimestamp();
            }
        );

        /**
         * Sort coupons by expiration when order by is set to 'expire/desc'.
         * This needs to be handled via PHP so coupons with expiry are prioritized than coupons that have no expiry.
         */
        if ( 'expire/asc' === $attributes['order_by'] ) {
            $this->sort_coupons_list_by_expiry( $coupons, 'asc' );
        }

        ob_start();

        $this->load_coupons_list_template(
            $coupons,
            'acfw-coupons-by-category-block',
            $attributes
        );

        // reset the wp query global data to make sure we don't override the default post object in the page.
        wp_reset_postdata();

        return ob_get_clean();
    }

    /**
     * Render the single coupon block on frontend.
     *
     * @since 3.1
     * @access public
     *
     * @param array $attributes Block attributes.
     * @return string HTML markup of block.
     */
    public function render_single_coupon_block( $attributes ) {
        // don't proceed if there is no coupon ID.
        if ( ! is_array( $attributes ) || ! isset( $attributes['coupon_id'] ) ) {
            return '';
        }

        $defaults   = $this->_get_single_coupon_atts( true );
        $attributes = $this->_sanitize_parse_atts( $attributes, $defaults );

        // Replace extract() with explicit variable assignment for security.
        $coupon_id         = isset( $attributes['coupon_id'] ) ? $attributes['coupon_id'] : 0;
        $contentVisibility = isset( $attributes['contentVisibility'] ) ? $attributes['contentVisibility'] : array();
        $className         = isset( $attributes['className'] ) ? implode( ' ', $this->_helper_functions->sanitize_css_classes( $attributes['className'] ) ) : '';

        // Security: Validate coupon access before rendering.
        if ( ! $this->_can_user_view_coupon( absint( $coupon_id ) ) ) {
            return '';
        }

        $is_premium = $this->_helper_functions->is_plugin_active( Plugin_Constants::PREMIUM_PLUGIN );
        $coupon     = $is_premium ? new \ACFWP\Models\Objects\Advanced_Coupon( absint( $coupon_id ) ) : new Advanced_Coupon( absint( $coupon_id ) );

        ob_start();
        $this->_helper_functions->load_single_coupon_template( $coupon, (object) $contentVisibility, $className );
        return ob_get_clean();
    }

    /**
     * Render the single coupon block on frontend.
     *
     * @since 3.1
     * @access public
     *
     * @param array $attributes Block attributes.
     * @return string HTML markup of block.
     */
    public function render_coupons_by_customer_block( $attributes ) {
        $attributes = is_array( $attributes ) ? $attributes : array();
        $defaults   = $this->_get_coupons_by_customer_atts( true );
        $attributes = $this->_sanitize_parse_atts( $attributes, $defaults );

        return apply_filters( 'acfw_render_coupons_by_customer_block', '', $attributes );
    }

    /*
    |--------------------------------------------------------------------------
    | Templates
    |--------------------------------------------------------------------------
     */

    /**
     * Load coupons list template.
     *
     * @since 3.1
     * @access public
     *
     * @param array $coupons     Coupons objects list.
     * @param array $block_class List of classnames for the block.
     * @param array $attributes  Block attributes.
     */
    public function load_coupons_list_template( $coupons, $block_class, $attributes ) {
        // Replace extract() with explicit variable assignment for security.
        $columns           = isset( $attributes['columns'] ) ? $attributes['columns'] : 1;
        $contentVisibility = isset( $attributes['contentVisibility'] ) ? $attributes['contentVisibility'] : array();

        $classnames     = array( 'acfw-coupons-list-block' );
        $classnames[]   = $block_class;
        $column_percent = 100 / $columns;
        $styles         = array(
            'grid-template-columns:' . str_repeat( ' ' . $column_percent . '%', $columns ),
            'max-width: ' . ( $columns * 300 ) . 'px',
        );

        // add custom class value from "Advanced" panel.
        // Check if className key exists in attributes to match original extract() behavior.
        if ( isset( $attributes['className'] ) && ! empty( $attributes['className'] ) ) {
            $classnames = array_merge( $classnames, $this->_helper_functions->sanitize_css_classes( $attributes['className'] ) );
        }

        $this->_helper_functions->load_template(
            'acfw-blocks/coupons-list.php',
            array(
                'coupons'           => $coupons,
                'classnames'        => $classnames,
                'helper_functions'  => $this->_helper_functions,
                'columns'           => $columns,
                'styles'            => $styles,
                'contentVisibility' => (object) $contentVisibility,
            )
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Utilities
    |--------------------------------------------------------------------------
     */

    /**
     * Check if the current user can view a specific coupon.
     *
     * This method validates coupon access based on post status and user capabilities
     * to prevent unauthorized access to private or draft coupons.
     *
     * @since 4.7.2
     * @access private
     *
     * @param int $coupon_id Coupon ID.
     * @return bool True if user can view the coupon, false otherwise.
     */
    private function _can_user_view_coupon( $coupon_id ) {
        // Validate coupon ID.
        if ( empty( $coupon_id ) ) {
            return false;
        }

        $coupon_post = get_post( $coupon_id );

        // Check if coupon exists and is valid post type.
        if ( ! $coupon_post || 'shop_coupon' !== $coupon_post->post_type ) {
            return false;
        }

        $post_status = $coupon_post->post_status;

        // Published coupons are accessible to everyone.
        if ( 'publish' === $post_status ) {
            return true;
        }

        // Private coupons require 'manage_woocommerce' capability.
        if ( 'private' === $post_status ) {
            return current_user_can( 'manage_woocommerce' );
        }

        // Draft, pending, and other non-public statuses require edit capability or being the author.
        if ( in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true ) ) {
            // Allow if user can edit this specific coupon or is the author.
            $can_edit  = current_user_can( 'edit_post', $coupon_id );
            $is_author = is_user_logged_in() && get_current_user_id() === (int) $coupon_post->post_author;
            return $can_edit || $is_author;
        }

        // Trash and other statuses are not accessible via shortcode.
        return false;
    }

    /**
     * Append the sort parameters to the query arguments based on the selected 'order_by' attribute of the block.
     *
     * NOTE: When $order_by_raw is set to "expire/asc", the coupons with no expiration date value will be listed first.
     *       This is due to the nature of the ASC order for a custom meta value as WP will always prioritize coupons/posts
     *       that doesn't have the custom meta value.
     *
     * @since 3.1
     * @access private
     *
     * @param array  $args Query args.
     * @param string $order_by_raw Order by attribute value.
     * @return array Query args with sort parameters.
     */
    private function _append_sort_arguments_for_query( $args, $order_by_raw ) {
        list($order_by, $order) = explode( '/', $order_by_raw );

        if ( 'expire' === $order_by ) {

            if ( $this->_helper_functions->is_plugin_active( Plugin_Constants::PREMIUM_PLUGIN ) ) {
                $args['orderby']   = 'meta_value';
                $args['meta_key']  = Plugin_Constants::META_PREFIX . 'schedule_end';
                $args['meta_type'] = 'DATETIME';
            } else {
                $args['orderby']  = 'meta_value_num';
                $args['meta_key'] = 'date_expires';
            }
        } else {
            $args['orderby'] = $order_by;
        }

        $args['order'] = \strtoupper( $order );

        return $args;
    }

    /**
     * Sort coupons by expiration date.
     * This function ensures that coupons with set expiration date are listed first than coupons that have no expiry.
     *
     * @since 3.1
     * @access public
     *
     * @param array  $coupons Array of advanced coupon objects.
     * @param string $order   Sort order.
     */
    public function sort_coupons_list_by_expiry( &$coupons, $order ) {
        // Sort remaining coupons.
        usort(
            $coupons,
            function ( $a, $b ) use ( $order ) {
                $a_date = $a->get_date_expires( 'edit' );
                $b_date = $b->get_date_expires( 'edit' );

                if ( $a_date === $b_date ) {
                    return 0;
                }

                if ( ! $a_date && $b_date ) {
                    return 'desc' === $order ? -1 : 1;
                }

                if ( $a_date && ! $b_date ) {
                    return 'desc' === $order ? 1 : -1;
                }

                $condition = 'desc' === $order ? $a_date < $b_date : $a_date > $b_date;
                return ( $condition ) ? 1 : -1;
            }
        );
    }

    /**
     * Get coupon objects from queried IDs.
     *
     * @since 3.1
     * @access private
     *
     * @param int[] $coupon_ids List of coupon IDs.
     */
    private function _get_coupons_from_queried_ids( $coupon_ids ) {
        $is_premium = $this->_helper_functions->is_plugin_active( Plugin_Constants::PREMIUM_PLUGIN );

        return array_map(
            function ( $id ) use ( $is_premium ) {
            return $is_premium ? new \ACFWP\Models\Objects\Advanced_Coupon( absint( $id ) ) : new Advanced_Coupon( absint( $id ) );
            },
            $coupon_ids
        );
    }

    /**
     * Get display data for a single coupon.
     *
     * @since 3.1
     * @access private
     *
     * @param int $coupon_id Coupon ID.
     */
    private function _get_coupon_display_data( $coupon_id ) {
        return array(
            'coupon_id'   => $coupon_id,
            'coupon_code' => wc_get_coupon_code_by_id( $coupon_id ),
        );
    }

    /**
     * Get default attributes for the coupons by category gutenberg block.
     *
     * @since 3.1
     * @access private
     *
     * @param bool $defaults_only Set to true to only return default values.
     * @return array List of attributes or default values.
     */
    private function _get_coupons_by_category_atts( $defaults_only = false ) {
        $category_atts = array(
            'categories' => array(
                'type'    => 'array',
                'default' => array(),
            ),
        );

        $attributes = array_merge(
            $category_atts,
            $this->_get_layout_atts(),
            $this->_get_shared_atts()
        );

        return $defaults_only ? $this->_get_attributes_default_values( $attributes ) : $attributes;
    }

    /**
     * Get default attributes for the single coupon gutenberg block.
     *
     * @since 3.1
     * @access private
     *
     * @param bool $defaults_only Set to true to only return default values.
     * @return array List of attributes or default values.
     */
    private function _get_single_coupon_atts( $defaults_only = false ) {
        $attributes = array_merge(
            array(
                'coupon_id'   => array(
                    'type'    => 'number',
                    'default' => 0,
                ),
                'coupon_code' => array(
                    'type'    => 'string',
                    'default' => '',
                ),
            ),
            $this->_get_shared_atts()
        );

        return $defaults_only ? $this->_get_attributes_default_values( $attributes ) : $attributes;
    }

    /**
     * Get default attributes for the coupons by customer gutenberg block.
     *
     * @since 3.1
     * @access private
     *
     * @param bool $defaults_only Set to true to only return default values.
     * @return array List of attributes or default values.
     */
    private function _get_coupons_by_customer_atts( $defaults_only = false ) {
        $attributes = array_merge(
            array(
                'display_type' => array(
                    'type'    => 'string',
                    'default' => '',
                ),
            ),
            $this->_get_layout_atts(),
            $this->_get_shared_atts()
        );

        return $defaults_only ? $this->_get_attributes_default_values( $attributes ) : $attributes;
    }

    /**
     * Get the default values only from the list of attributes.
     *
     * @since 3.1
     * @access private
     *
     * @param array $attributes Gutenberg block attributes.
     * @return array List of default values for each attribute.
     */
    private function _get_attributes_default_values( $attributes ) {
        // add default classname attribute from "Advanced" panel.
        $attributes['className'] = array( 'default' => '' );

        return array_map(
            function ( $a ) {
            return $a['default'];
            },
            $attributes
        );
    }

    /**
     * Get attributes that is shared for blocks that require layout configurations.
     *
     * @since 3.1
     * @access private
     */
    private function _get_layout_atts() {
        return array(
            'order_by' => array(
                'type'    => 'string',
                'default' => 'date/desc',
            ),
            'columns'  => array(
                'type'    => 'number',
                'default' => 3,
            ),
            'count'    => array(
                'type'    => 'number',
                'default' => 10,
            ),
        );
    }

    /**
     * Get attributes that is required for all blocks.
     *
     * @since 3.1
     * @access private
     */
    private function _get_shared_atts() {
        return array(
            'contentVisibility' => array(
                'type'    => 'object',
                'default' => (object) array(
                    'discount_value'  => true,
                    'description'     => true,
                    'usage_limit'     => true,
                    'schedule'        => true,
                    'expired_coupons' => true,
                ),
            ),
            'isPreview'         => array(
                'type'    => 'boolean',
                'default' => false,
            ),
        );
    }

    /**
     * Sanitize and parse block attributes.
     *
     * @since 4.5.2
     * @access private
     *
     * @param array $attributes Block attributes.
     * @param array $defaults   Block attribute default values.
     * @return array Sanitized block attributes.
     */
    private function _sanitize_parse_atts( $attributes, $defaults ) {
        $attributes = $this->_format_attributes_from_shortcode( $attributes );
        $sanitized  = array();

        foreach ( $attributes as $key => $value ) {

            // skip if the attribute is not included in the defaults.
            if ( ! isset( $defaults[ $key ] ) ) {
                continue;
            }

            $type = gettype( $defaults[ $key ] );

            // convert comma delimited string to array for categories value.
            if ( 'categories' === $key && 'string' === gettype( $value ) ) {
                $value = explode( ',', $value );
            }

            // prepare values for content visibility option.
            if ( 'contentVisibility' === $key ) {
                $type  = 'objectboolean';
                $value = is_object( $value ) || is_array( $value ) ? (object) $value : json_decode( $value );

                // skip if the value is still not valid.
                if ( ! is_object( $value ) ) {
                    continue;
                }
            }

            $sanitized[ $key ] = $this->_helper_functions->api_sanitize_value( $value, $type );
        }

        return wp_parse_args( $sanitized, $defaults );
    }

    /**
     * Format attributes from shortcode so it will be valid to be used by the render functions.
     *
     * @since 4.5.2
     * @access private
     *
     * @param array $attributes Block attributes.
     * @return array Filtered attributes.
     */
    private function _format_attributes_from_shortcode( $attributes ) {
        if ( ! isset( $attributes['contentVisibility'] ) && ! empty( $attributes ) ) {
            $attributes['contentVisibility'] = (object) array(
                'discount_value'  => isset( $attributes['show_discount_value'] ) ? rest_sanitize_boolean( $attributes['show_discount_value'] ) : true,
                'description'     => isset( $attributes['show_description'] ) ? rest_sanitize_boolean( $attributes['show_description'] ) : true,
                'usage_limit'     => isset( $attributes['show_usage_limit'] ) ? rest_sanitize_boolean( $attributes['show_usage_limit'] ) : true,
                'schedule'        => isset( $attributes['show_schedule'] ) ? rest_sanitize_boolean( $attributes['show_schedule'] ) : true,
                'expired_coupons' => isset( $attributes['show_expired_coupons'] ) ? rest_sanitize_boolean( $attributes['show_expired_coupons'] ) : true,
            );
        }

        return $attributes;
    }

    /*
    |--------------------------------------------------------------------------
    | Fulfill implemented interface contracts
    |--------------------------------------------------------------------------
     */

    /**
     * Execute codes that needs to run plugin activation.
     *
     * @since 1.0
     * @access public
     * @implements ACFWF\Interfaces\Initializable_Interface
     */
    public function initialize() {
        $this->_register_blocks();
    }

    /**
     * Execute Editor_Blocks class.
     *
     * @since 1.1
     * @access public
     * @inherit ACFWF\Interfaces\Model_Interface
     */
    public function run() {
        if ( version_compare( $GLOBALS['wp_version'], '5.8', '<' ) ) {
            add_filter( 'block_categories', array( $this, 'register_acfw_block_category' ) );
        } else {
            add_filter( 'block_categories_all', array( $this, 'register_acfw_block_category' ) );
        }

        add_shortcode( 'acfw_coupon_by_categories', array( $this, 'render_coupons_by_category_block' ) );
        add_shortcode( 'acfw_single_coupon', array( $this, 'render_single_coupon_block' ) );
        add_shortcode( 'acfw_coupons_by_customer', array( $this, 'render_coupons_by_customer_block' ) );
    }
}
