<?php
/**
 * No-show tracking and automatic deposit requirement (Pro).
 *
 * 4.6.2: this feature was sold before it existed. `render_pro_gate()` in
 * class-yatoon-admin.php promised "Automatically flag clients with 3+
 * no-shows and require a deposit on future bookings", the Add-ons screen
 * listed "No-show Tracking", the dashboard nagged free sites about it -- and
 * the entire implementation was a single `include settings.php` that, because
 * it never set a tab, dropped the paying customer on the General tab. Nothing
 * in the codebase counted a client's no-shows or influenced a deposit
 * decision. This class is that missing implementation.
 *
 * Scope, stated honestly because the readme now says the same thing:
 *
 *  - Counting is per CONTACT, not per client row. `{prefix}yatoon_bookings`
 *    has no client_id foreign key; it carries customer_email/customer_phone
 *    and `{prefix}yatoon_clients` is matched back to it by those two columns
 *    (see the client lookup in class-yatoon-ajax.php::create_booking()). That
 *    weak link is pre-existing architectural debt and deliberately NOT
 *    changed here -- adding a column would mean a schema migration in a patch
 *    release. Everything below therefore matches on email/phone exactly the
 *    way the rest of the plugin already does, so a client who books under two
 *    different email addresses counts as two people. That is a known limit,
 *    not an oversight.
 *
 *  - Enforcement rides the `yatoon_require_deposit` filter added in 4.6.2.
 *    That filter only decides *whether* a deposit is required; the amount and
 *    the gateway still come from the site's existing Deposit & Prepay
 *    settings, and create_booking() additionally requires a configured,
 *    credentialed gateway before it will actually ask for money. So on a site
 *    with no deposit gateway configured this feature can flag a client but
 *    cannot charge one -- the settings screen says so out loud instead of
 *    letting a merchant believe deposits are being collected.
 *
 *  - The listener is attached ONLY when the feature is switched on by a Pro
 *    site. create_booking() guards its whole context payload behind
 *    `has_filter( 'yatoon_require_deposit' )` precisely so that an unused
 *    feature costs zero extra queries; attaching an inert listener would
 *    defeat that guard and make every Free booking pay for a client lookup
 *    that nothing reads.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class YATOON_No_Show {

    const OPT_ENABLED   = 'yatoon_no_show_tracking_enabled';
    const OPT_THRESHOLD = 'yatoon_no_show_threshold';
    const OPT_WINDOW    = 'yatoon_no_show_window_months';
    const OPT_ACTION    = 'yatoon_no_show_action';
    const OPT_EXEMPT    = 'yatoon_no_show_exemptions';

    const DEFAULT_THRESHOLD = 3;
    const DEFAULT_WINDOW    = 12;

    /** Per-request memo so one booking request never counts the same contact twice. */
    private static $count_cache = array();

    public static function init() {
        // Write endpoints are registered unconditionally: they are pure
        // add_action() calls (no queries, no options read) and each one
        // re-checks capability, nonce and licence before touching anything.
        // Registering them behind is_active() would make the "turn the
        // feature on" button unreachable while the feature is off.
        add_action( 'admin_post_yatoon_save_no_show', array( __CLASS__, 'handle_save' ) );
        add_action( 'admin_post_yatoon_no_show_exempt', array( __CLASS__, 'handle_exempt' ) );

        if ( ! self::is_active() ) {
            return;
        }

        // Only the deposit action needs the runtime filter. In "flag only"
        // mode the booking path stays byte-for-byte identical to a site with
        // the feature switched off.
        if ( 'deposit' === self::action() ) {
            add_filter( 'yatoon_require_deposit', array( __CLASS__, 'filter_require_deposit' ), 10, 2 );
        }

        if ( is_admin() ) {
            add_action( 'admin_notices', array( __CLASS__, 'maybe_render_admin_notice' ) );
        }
    }

    // -- State ---------------------------------------------------------------

    /**
     * Is the feature switched on by a licensed Pro site?
     *
     * The licence check runs FIRST and deliberately reads no options: a Free
     * site must be able to answer "no" without touching the database.
     */
    public static function is_active() {
        if ( ! self::is_pro() ) {
            return false;
        }
        return '1' === (string) get_option( self::OPT_ENABLED, '0' );
    }

    public static function is_pro() {
        if ( class_exists( 'YATOON_License' ) ) {
            return (bool) YATOON_License::is_pro();
        }
        // class-yatoon-license.php is required after this file in the
        // bootstrap order, so fall back to the same helper it wraps.
        return function_exists( 'ybs_is_pro' ) ? (bool) ybs_is_pro() : false;
    }

    /** Number of no-shows inside the window that trips the rule. Never below 1. */
    public static function threshold() {
        $threshold = (int) get_option( self::OPT_THRESHOLD, self::DEFAULT_THRESHOLD );
        return max( 1, min( 50, $threshold ) );
    }

    /** How far back the count looks, in months. */
    public static function window_months() {
        $months = (int) get_option( self::OPT_WINDOW, self::DEFAULT_WINDOW );
        return max( 1, min( 120, $months ) );
    }

    /** 'deposit' (require a deposit) or 'flag' (only surface it to staff). */
    public static function action() {
        $action = sanitize_key( (string) get_option( self::OPT_ACTION, 'deposit' ) );
        return 'flag' === $action ? 'flag' : 'deposit';
    }

    /**
     * Start of the counting window as a local-time MySQL datetime.
     *
     * `yatoon_bookings.start_time` is stored in the site's local time (the
     * separate created_at_gmt column is the only GMT one), so the cutoff has
     * to be local time too. Using gmdate( ..., time() ) here would shift the
     * window by the site's UTC offset and quietly include or exclude a day's
     * worth of appointments at the boundary.
     */
    public static function window_cutoff() {
        $now = strtotime( (string) current_time( 'mysql' ) );
        if ( ! $now ) {
            $now = time();
        }
        return gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::window_months() . ' months', $now ) );
    }

    // -- Counting ------------------------------------------------------------

    /**
     * No-shows recorded for this contact inside the window.
     *
     * Matching mirrors the client lookup already in create_booking(): an
     * exact match on customer_email OR customer_phone. Both columns are
     * indexed, and the comparison is left to the column collation (which is
     * case-insensitive on every default WordPress install) rather than
     * wrapped in LOWER() -- LOWER( customer_email ) would make the
     * customer_email index unusable for no benefit.
     *
     * Status is compared against both spellings the codebase writes, matching
     * the reporting queries in class-yatoon-reporting.php.
     *
     * @param string $email Customer email as submitted.
     * @param string $phone Customer phone as submitted.
     * @return int
     */
    public static function count_no_shows( $email, $phone ) {
        global $wpdb;

        $email = trim( (string) $email );
        $phone = trim( (string) $phone );
        if ( '' === $email && '' === $phone ) {
            return 0;
        }

        $cache_key = $email . '|' . $phone;
        if ( isset( self::$count_cache[ $cache_key ] ) ) {
            return self::$count_cache[ $cache_key ];
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- memoised per request above; a persistent cache is wrong here because staff can mark an appointment no-show and immediately re-book the client.
        $count = (int) yatoon_db_get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_bookings
              WHERE status IN ( 'no_show', 'NO_SHOW' )
                AND start_time >= %s
                AND ( ( %s <> '' AND customer_email = %s ) OR ( %s <> '' AND customer_phone = %s ) )",
            self::window_cutoff(),
            $email,
            $email,
            $phone,
            $phone
        ) );

        self::$count_cache[ $cache_key ] = $count;
        return $count;
    }

    /**
     * Contacts at or over the threshold, newest offence first.
     *
     * Grouped by "email if there is one, otherwise phone" so a client who
     * always books with the same address collapses into one row. This is the
     * same weak identity the rest of the plugin uses; a client who books once
     * by email and once by phone only will appear twice. Exempting either row
     * exempts the contact details on it, so both rows can be cleared.
     *
     * @param int $limit Hard-capped row count.
     * @return array List of rows with name/email/phone/count/last/exempt.
     */
    public static function offenders( $limit = 50 ) {
        global $wpdb;

        $limit = max( 1, min( 200, (int) $limit ) );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin-only report rendered on demand; caching it would show stale flags right after staff marks a no-show.
        $rows = yatoon_db_get_results( $wpdb->prepare(
            "SELECT COALESCE( NULLIF( customer_email, '' ), CONCAT( 'phone:', customer_phone ) ) AS identity_key,
                    MAX( customer_name )  AS customer_name,
                    MAX( customer_email ) AS customer_email,
                    MAX( customer_phone ) AS customer_phone,
                    COUNT(*)              AS no_show_count,
                    MAX( start_time )     AS last_no_show
               FROM {$wpdb->prefix}yatoon_bookings
              WHERE status IN ( 'no_show', 'NO_SHOW' )
                AND start_time >= %s
                AND ( customer_email <> '' OR customer_phone <> '' )
              GROUP BY identity_key
             HAVING no_show_count >= %d
              ORDER BY no_show_count DESC, last_no_show DESC
              LIMIT %d",
            self::window_cutoff(),
            self::threshold(),
            $limit
        ) );

        $out = array();
        foreach ( (array) $rows as $row ) {
            $out[] = array(
                'name'    => (string) $row->customer_name,
                'email'   => (string) $row->customer_email,
                'phone'   => (string) $row->customer_phone,
                'count'   => (int) $row->no_show_count,
                'last'    => (string) $row->last_no_show,
                'exempt'  => self::is_exempt( $row->customer_email, $row->customer_phone, 0 ),
            );
        }
        return $out;
    }

    /** Offenders who are not exempt -- i.e. the ones the rule will actually act on. */
    public static function active_offenders( $limit = 200 ) {
        return array_values( array_filter( self::offenders( $limit ), static function ( $row ) {
            return empty( $row['exempt'] );
        } ) );
    }

    // -- Exemptions ----------------------------------------------------------

    /**
     * Manual exemptions.
     *
     * Stored as an option of opaque keys rather than client IDs because the
     * bookings table has no client ID to key on. A key is one of:
     *   c:<id>    a yatoon_clients row id (available from the filter context)
     *   e:<email> a normalised email address
     *   p:<phone> a phone number reduced to digits
     * Exempting one contact writes every key that identifies it, so the
     * exemption survives whichever of the three the booking supplies.
     *
     * Without this, one mistaken "No Show" click on a loyal client would
     * silently start demanding deposits from them with no way back.
     */
    public static function exemptions() {
        $list = get_option( self::OPT_EXEMPT, array() );
        return is_array( $list ) ? array_values( array_unique( array_map( 'strval', $list ) ) ) : array();
    }

    public static function normalize_email( $email ) {
        return strtolower( trim( (string) $email ) );
    }

    public static function normalize_phone( $phone ) {
        return preg_replace( '/\D+/', '', (string) $phone );
    }

    /** Every exemption key that identifies this contact. */
    public static function identity_keys( $email, $phone, $client_id = 0 ) {
        $keys = array();
        $client_id = (int) $client_id;
        if ( $client_id > 0 ) {
            $keys[] = 'c:' . $client_id;
        }
        $normalized_email = self::normalize_email( $email );
        if ( '' !== $normalized_email ) {
            $keys[] = 'e:' . $normalized_email;
        }
        $normalized_phone = self::normalize_phone( $phone );
        if ( '' !== $normalized_phone ) {
            $keys[] = 'p:' . $normalized_phone;
        }
        return $keys;
    }

    public static function is_exempt( $email, $phone, $client_id = 0 ) {
        $list = self::exemptions();
        if ( empty( $list ) ) {
            return false;
        }
        foreach ( self::identity_keys( $email, $phone, $client_id ) as $key ) {
            if ( in_array( $key, $list, true ) ) {
                return true;
            }
        }
        return false;
    }

    public static function set_exempt( $email, $phone, $client_id, $exempt ) {
        $keys = self::identity_keys( $email, $phone, $client_id );
        if ( empty( $keys ) ) {
            return false;
        }
        $list = self::exemptions();
        $list = $exempt
            ? array_merge( $list, $keys )
            : array_diff( $list, $keys );
        $list = array_values( array_unique( $list ) );
        // Bounded so a runaway UI can never grow an unbounded autoloaded row.
        if ( count( $list ) > 2000 ) {
            $list = array_slice( $list, -2000 );
        }
        update_option( self::OPT_EXEMPT, $list, false );
        return true;
    }

    // -- Enforcement ---------------------------------------------------------

    /**
     * Require a deposit from a repeat no-show.
     *
     * Contract (fixed by class-yatoon-ajax.php::create_booking()):
     *   @param bool  $required Current decision from site settings.
     *   @param array $context  client_id, email, phone, service_id, amount.
     *   @return bool
     *
     * Never returns false when the site already said true -- this rule can
     * only add a deposit requirement, never remove one a merchant configured.
     * That also means the common case (deposits already on for everyone)
     * short-circuits before any query runs.
     */
    public static function filter_require_deposit( $required, $context ) {
        if ( $required ) {
            return true;
        }
        if ( ! is_array( $context ) ) {
            return $required;
        }

        $email     = isset( $context['email'] ) ? (string) $context['email'] : '';
        $phone     = isset( $context['phone'] ) ? (string) $context['phone'] : '';
        $client_id = isset( $context['client_id'] ) ? (int) $context['client_id'] : 0;

        if ( '' === trim( $email ) && '' === trim( $phone ) ) {
            return $required;
        }
        if ( self::is_exempt( $email, $phone, $client_id ) ) {
            return $required;
        }
        if ( self::count_no_shows( $email, $phone ) < self::threshold() ) {
            return $required;
        }
        return true;
    }

    // -- Admin ---------------------------------------------------------------

    private static function authorize() {
        if ( ! current_user_can( 'yatoon_manage_settings' ) && ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to change no-show settings.', 'yatoon-booking-system' ) );
        }
        if ( ! self::is_pro() ) {
            wp_die( esc_html__( 'No-show tracking requires an active Pro license.', 'yatoon-booking-system' ) );
        }
    }

    public static function settings_url( $args = array() ) {
        return add_query_arg(
            array_merge( array( 'page' => 'yatoon-noshow-pro' ), $args ),
            admin_url( 'admin.php' )
        );
    }

    public static function handle_save() {
        self::authorize();
        check_admin_referer( 'yatoon_save_no_show' );

        $enabled   = isset( $_POST['yatoon_no_show_tracking_enabled'] ) ? '1' : '0';
        $threshold = isset( $_POST['yatoon_no_show_threshold'] ) ? absint( wp_unslash( $_POST['yatoon_no_show_threshold'] ) ) : self::DEFAULT_THRESHOLD;
        $window    = isset( $_POST['yatoon_no_show_window_months'] ) ? absint( wp_unslash( $_POST['yatoon_no_show_window_months'] ) ) : self::DEFAULT_WINDOW;
        $action    = isset( $_POST['yatoon_no_show_action'] ) ? sanitize_key( wp_unslash( $_POST['yatoon_no_show_action'] ) ) : 'deposit';

        update_option( self::OPT_ENABLED, $enabled, false );
        update_option( self::OPT_THRESHOLD, max( 1, min( 50, $threshold ) ), false );
        update_option( self::OPT_WINDOW, max( 1, min( 120, $window ) ), false );
        update_option( self::OPT_ACTION, 'flag' === $action ? 'flag' : 'deposit', false );
        self::flush_notice_cache();

        wp_safe_redirect( self::settings_url( array( 'yatoon_no_show_saved' => 1 ) ) );
        exit;
    }

    public static function handle_exempt() {
        self::authorize();
        check_admin_referer( 'yatoon_no_show_exempt' );

        $email     = isset( $_POST['contact_email'] ) ? sanitize_email( wp_unslash( $_POST['contact_email'] ) ) : '';
        $phone     = isset( $_POST['contact_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['contact_phone'] ) ) : '';
        $client_id = isset( $_POST['client_id'] ) ? absint( wp_unslash( $_POST['client_id'] ) ) : 0;
        $exempt    = isset( $_POST['exempt'] ) && '1' === (string) wp_unslash( $_POST['exempt'] );

        self::set_exempt( $email, $phone, $client_id, $exempt );
        self::flush_notice_cache();

        wp_safe_redirect( self::settings_url( array( 'yatoon_no_show_saved' => $exempt ? 2 : 3 ) ) );
        exit;
    }

    /**
     * Tell an administrator that clients are flagged.
     *
     * Deliberately narrow: only on Yatoon's own admin screens, only for users
     * who could act on it, never on the no-show screen itself (which already
     * shows the full list), and behind a 6-hour transient so browsing the
     * admin does not re-run the GROUP BY on every page load.
     */
    public static function maybe_render_admin_notice() {
        if ( ! function_exists( 'yatoon_is_plugin_admin_request' ) || ! yatoon_is_plugin_admin_request() ) {
            return;
        }
        if ( ! current_user_can( 'yatoon_manage_settings' ) && ! current_user_can( 'manage_options' ) ) {
            return;
        }
        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen routing, no state change.
        if ( 'yatoon-noshow-pro' === $page ) {
            return;
        }

        $count = get_transient( 'yatoon_no_show_flagged_count' );
        if ( false === $count ) {
            $count = count( self::active_offenders( 200 ) );
            set_transient( 'yatoon_no_show_flagged_count', $count, 6 * HOUR_IN_SECONDS );
        }
        $count = (int) $count;
        if ( $count < 1 ) {
            return;
        }

        $message = 'deposit' === self::action()
            ? sprintf(
                /* translators: %1$d: number of flagged clients. %2$d: no-show threshold. */
                _n(
                    '%1$d client has reached %2$d or more no-shows and will be asked for a deposit on their next booking.',
                    '%1$d clients have reached %2$d or more no-shows and will be asked for a deposit on their next booking.',
                    $count,
                    'yatoon-booking-system'
                ),
                $count,
                self::threshold()
            )
            : sprintf(
                /* translators: %1$d: number of flagged clients. %2$d: no-show threshold. */
                _n(
                    '%1$d client has reached %2$d or more no-shows. Deposits are not being requested because this rule is set to flag only.',
                    '%1$d clients have reached %2$d or more no-shows. Deposits are not being requested because this rule is set to flag only.',
                    $count,
                    'yatoon-booking-system'
                ),
                $count,
                self::threshold()
            );

        printf(
            '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
            esc_html( $message ),
            esc_url( self::settings_url() ),
            esc_html__( 'Review no-show tracking', 'yatoon-booking-system' )
        );
    }

    /** Invalidate the notice cache after a settings or exemption change. */
    public static function flush_notice_cache() {
        delete_transient( 'yatoon_no_show_flagged_count' );
    }
}
