<?php
/**
 * AJAX Handler — replaces REST API entirely.
 *
 * Uses WordPress wp_ajax_ hooks which work on ALL sites regardless of
 * REST API restrictions, security plugins, or server configuration.
 * Every action is nonce-verified and capability-checked.
 *
 * @package EmpRoster
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class EMPR_Ajax {

    /**
     * Fixed conversion used for paid-leave pay calculations: one leave day
     * equals this many hours, regardless of the employee's job type. Leave is
     * displayed and summarised in days everywhere; this constant is only used
     * to derive a dollar amount (days × hours-per-day × pay rate).
     */
    const LEAVE_HOURS_PER_DAY = 7.5;

    public static function init() {
        $free_actions = array(
            // Shifts
            'empr_get_shifts'         => array( 'cb' => 'get_shifts',         'manager' => false ),
            'empr_create_shift'       => array( 'cb' => 'create_shift',       'manager' => true  ),
            'empr_update_shift'       => array( 'cb' => 'update_shift',       'manager' => true  ),
            'empr_delete_shift'            => array( 'cb' => 'delete_shift',            'manager' => true  ),
            'empr_sync_assignments'        => array( 'cb' => 'sync_assignments',        'manager' => true  ),
            // Timesheets
            'empr_get_timesheets'     => array( 'cb' => 'get_timesheets',     'manager' => true  ),
            'empr_get_timesheet_user' => array( 'cb' => 'get_timesheet_user', 'manager' => false ),
            // Availability
            'empr_get_availability'   => array( 'cb' => 'get_availability',  'manager' => false ),
            'empr_save_availability'  => array( 'cb' => 'save_availability', 'manager' => false ),
            'empr_all_availability'   => array( 'cb' => 'all_availability',  'manager' => true  ),
            // Profile (self)
            'empr_get_profile'        => array( 'cb' => 'get_profile',       'manager' => false ),
            'empr_update_profile'     => array( 'cb' => 'update_profile',    'manager' => false ),
            'empr_change_password'    => array( 'cb' => 'change_password',   'manager' => false ),
            // Staff & misc
            'empr_get_staff'          => array( 'cb' => 'get_staff',         'manager' => true  ),
            'empr_create_staff'       => array( 'cb' => 'create_staff',      'manager' => true  ),
            'empr_update_staff'       => array( 'cb' => 'update_staff',      'manager' => true  ),
            'empr_deactivate_staff'        => array( 'cb' => 'deactivate_staff',       'manager' => true  ),
            'empr_get_deactivated_staff'   => array( 'cb' => 'get_deactivated_staff',  'manager' => true  ),
            'empr_reactivate_staff'        => array( 'cb' => 'reactivate_staff',       'manager' => true  ),
            'empr_get_dashboard'      => array( 'cb' => 'get_dashboard',     'manager' => false ),
            'empr_get_settings'       => array( 'cb' => 'get_settings',      'manager' => false ),
        );

        $leave_on = EMPR_Settings::get( 'leave_enabled', '1' ) === '1';

        foreach ( $free_actions as $action => $cfg ) {
            if ( ! empty( $cfg['leave'] ) && ! $leave_on ) continue;
            add_action( 'wp_ajax_' . $action, array( __CLASS__, $cfg['cb'] ) );
        }

        // Optional extension point. Additional action registrations may be supplied
        // by a companion fragment; when none is present this class registers exactly
        // the actions declared above.
        if ( method_exists( __CLASS__, 'register_optional_actions' ) ) {
            self::register_optional_actions( $leave_on );
        }
    }

    /* ── Helpers ─────────────────────────────────────────────────────────── */

    private static function verify() {
        if ( ! check_ajax_referer( 'empr_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
        }
        if ( ! is_user_logged_in() ) {
            wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'employee-roster' ) ), 401 );
        }
    }

    private static function verify_manager() {
        self::verify();
        if ( ! EMPR_Roles::is_manager() ) {
            wp_send_json_error( array( 'message' => __( 'Manager access required.', 'employee-roster' ) ), 403 );
        }
    }

    /** Passes for WP administrators. */
    private static function verify_can_view() {
        self::verify();
        if ( ! EMPR_Roles::can_view_all() ) {
            wp_send_json_error( array( 'message' => __( 'Access denied.', 'employee-roster' ) ), 403 );
        }
    }

    private static function post_json() {
        $raw = file_get_contents( 'php://input' );
        return $raw ? (array) json_decode( $raw, true ) : array();
    }

    private static function ok( $data ) {
        wp_send_json_success( $data );
    }

    /* ── Shifts ──────────────────────────────────────────────────────────── */

    public static function get_shifts() {
        self::verify();
        $args = array();
        if ( ! EMPR_Roles::can_view_all() ) $args['user_id'] = get_current_user_id();
        if ( ! empty( $_GET['start'] ) )   $args['start']   = sanitize_text_field( $_GET['start'] );
        if ( ! empty( $_GET['end'] ) )     $args['end']     = sanitize_text_field( $_GET['end'] );
        if ( ! empty( $_GET['status'] ) )  $args['status']  = sanitize_text_field( $_GET['status'] );
        self::ok( EMPR_Shifts::query( $args ) );
    }

    public static function create_shift() {
        self::verify_manager();

        $raw  = self::post_json();
        $data = EMPR_Shifts::sanitize( $raw );
        if ( is_wp_error( $data ) ) {
            wp_send_json_error( array( 'message' => $data->get_error_message() ), 400 );
        }

        // Direct shifts must have at least one employee assigned
        $user_ids = isset( $raw['user_ids'] ) ? array_map( 'absint', (array) $raw['user_ids'] ) : array();
        $user_ids = array_values( array_filter( $user_ids ) ); // remove zeros; re-index
        if ( $data['assignment_type'] === 'direct' && empty( $user_ids ) ) {
            wp_send_json_error( array( 'message' => __( 'Please assign at least one employee to this shift.', 'employee-roster' ) ), 400 );
        }

        // For direct shifts with staff selected, validate conflicts before persisting anything
        if ( $data['assignment_type'] === 'direct' && ! empty( $user_ids ) ) {
            // Approved-leave conflicts: an employee on approved leave cannot be rostered that day.
            $leave_conflicts = EMPR_Assignments::check_leave_conflicts(
                $data['start_datetime'],
                $data['end_datetime'],
                $user_ids
            );
            if ( ! empty( $leave_conflicts ) ) {
                $messages = array_map( function ( $e ) { return $e->get_error_message(); }, $leave_conflicts );
                wp_send_json_error( array(
                    'message' => implode( ' | ', $messages ),
                    'code'    => 'leave_overlap',
                ), 409 );
            }

            $conflicts = EMPR_Assignments::check_time_conflicts(
                $data['start_datetime'],
                $data['end_datetime'],
                $user_ids
            );
            if ( ! empty( $conflicts ) ) {
                $messages = array_map( function ( $e ) { return $e->get_error_message(); }, $conflicts );
                wp_send_json_error( array(
                    'message' => implode( ' | ', $messages ),
                    'code'    => 'assignment_conflict',
                ), 409 );
            }
        }

        // For direct assignment with multiple employees, create one independent shift
        // record per employee so that editing one person's shift never affects another.
        if ( $data['assignment_type'] === 'direct' && count( $user_ids ) > 1 ) {
            $ids          = array();
            $notify_pairs = array(); // uid => shift_id
            foreach ( $user_ids as $uid ) {
                $shift_id              = EMPR_Shifts::create( $data );
                EMPR_Assignments::sync( $shift_id, array( $uid ) );
                $ids[]                 = $shift_id;
                $notify_pairs[ $uid ]  = $shift_id;
            }
            if ( class_exists( 'EMPR_Notifications' ) ) {
                foreach ( $notify_pairs as $uid => $shift_id ) {
                    $new_shift = EMPR_Shifts::get( $shift_id );
                    if ( $new_shift ) {
                        EMPR_Notifications::shift_assigned( $uid, $new_shift );
                    }
                }
            }
            self::ok( array( 'ids' => $ids, 'message' => __( 'Shifts created.', 'employee-roster' ) ) );
            return;
        }

        // Single direct assignment or open shift: one shared record
        $id = EMPR_Shifts::create( $data );
        if ( ! empty( $user_ids ) ) {
            EMPR_Assignments::sync( $id, $user_ids );
            if ( class_exists( 'EMPR_Notifications' ) ) {
                $new_shift = EMPR_Shifts::get( $id );
                if ( $new_shift ) {
                    foreach ( $user_ids as $uid ) {
                        EMPR_Notifications::shift_assigned( $uid, $new_shift );
                    }
                }
            }
        }

        self::ok( array( 'id' => $id, 'message' => __( 'Shift created.', 'employee-roster' ) ) );
    }

    public static function update_shift() {
        self::verify_manager();
        $body = self::post_json();
        $id   = absint( isset( $body['id'] ) ? $body['id'] : 0 );
        unset( $body['id'] );

        // Capture shift before update so we can detect timing changes
        $old_shift = EMPR_Shifts::get( $id );

        $data = EMPR_Shifts::sanitize( $body );
        if ( is_wp_error( $data ) ) {
            wp_send_json_error( array( 'message' => $data->get_error_message() ), 400 );
        }

        // If the shift is being moved in time, ensure no currently confirmed assignee
        // ends up scheduled on a day they have approved leave for.
        if ( $old_shift && isset( $data['start_datetime'], $data['end_datetime'] ) ) {
            $time_changed = (
                $data['start_datetime'] !== $old_shift->start_datetime ||
                $data['end_datetime']   !== $old_shift->end_datetime
            );
            if ( $time_changed ) {
                $confirmed_ids = array();
                foreach ( EMPR_Assignments::for_shift( $id ) as $row ) {
                    if ( isset( $row->status ) && $row->status === 'confirmed' ) {
                        $confirmed_ids[] = (int) $row->user_id;
                    }
                }
                if ( ! empty( $confirmed_ids ) ) {
                    $leave_conflicts = EMPR_Assignments::check_leave_conflicts(
                        $data['start_datetime'],
                        $data['end_datetime'],
                        $confirmed_ids
                    );
                    if ( ! empty( $leave_conflicts ) ) {
                        $messages = array_map( function ( $e ) { return $e->get_error_message(); }, $leave_conflicts );
                        wp_send_json_error( array(
                            'message' => implode( ' | ', $messages ),
                            'code'    => 'leave_overlap',
                        ), 409 );
                    }
                }
            }
        }

        EMPR_Shifts::update( $id, $data );

        // Notify assigned employees if the shift start or end time changed
        if ( $old_shift && class_exists( 'EMPR_Notifications' ) ) {
            $time_changed = (
                ( isset( $data['start_datetime'] ) && $data['start_datetime'] !== $old_shift->start_datetime ) ||
                ( isset( $data['end_datetime'] )   && $data['end_datetime']   !== $old_shift->end_datetime )
            );
            if ( $time_changed ) {
                $updated_shift = EMPR_Shifts::get( $id );
                if ( $updated_shift ) {
                    // Only notify confirmed assignees — pending open_approval requesters
                    // are not yet scheduled for this shift, so a time-change email would
                    // imply a confirmation that has not happened.
                    $assignees = EMPR_Assignments::for_shift( $id );
                    foreach ( $assignees as $row ) {
                        if ( isset( $row->status ) && $row->status !== 'confirmed' ) continue;
                        EMPR_Notifications::shift_updated( (int) $row->user_id, $updated_shift );
                    }
                }
            }
        }

        self::ok( array( 'message' => __( 'Shift updated.', 'employee-roster' ) ) );
    }

    public static function delete_shift() {
        self::verify_manager();
        $body = self::post_json();
        $id   = absint( isset( $body['id'] ) ? $body['id'] : ( isset( $_POST['id'] ) ? $_POST['id'] : 0 ) );
        EMPR_Shifts::delete( $id );
        self::ok( array( 'message' => __( 'Shift deleted.', 'employee-roster' ) ) );
    }

    public static function sync_assignments() {
        self::verify_manager();
        $body     = self::post_json();
        $shift_id = absint( isset( $body['shift_id'] ) ? $body['shift_id'] : 0 );
        $user_ids = isset( $body['user_ids'] ) ? array_map( 'absint', (array) $body['user_ids'] ) : array();

        // Record currently CONFIRMED assignees before syncing. A pending open_approval
        // requester promoted to confirmed via this flow counts as newly added so the
        // employee receives the standard "you're scheduled" notification.
        $before_rows = EMPR_Assignments::for_shift( $shift_id );
        $before_ids  = array_map( 'intval', wp_list_pluck(
            array_values( array_filter( $before_rows, function ( $r ) {
                return isset( $r->status ) && $r->status === 'confirmed';
            } ) ),
            'user_id'
        ) );

        $errors = EMPR_Assignments::sync( $shift_id, $user_ids );

        if ( ! empty( $errors ) ) {
            $messages = array_map( function ( $e ) { return $e->get_error_message(); }, $errors );
            // Partial success: some removed/added, but conflicts blocked some additions
            wp_send_json_error( array(
                'message' => implode( "\n", $messages ),
                'code'    => 'conflict',
            ), 409 );
        }

        // Notify employees who were not previously CONFIRMED on this shift
        $newly_added = array_diff( array_map( 'intval', $user_ids ), $before_ids );
        if ( ! empty( $newly_added ) && class_exists( 'EMPR_Notifications' ) ) {
            $shift = EMPR_Shifts::get( $shift_id );
            if ( $shift ) {
                foreach ( $newly_added as $uid ) {
                    EMPR_Notifications::shift_assigned( $uid, $shift );
                }
            }
        }

        // If a manager direct-assignment has filled the shift, auto-decline any
        // remaining pending open_approval requests so the Requests page stays
        // consistent and no further employees can be approved into 0 slots.
        $auto_declined = EMPR_Assignments::decline_pending_if_full( $shift_id );
        $msg = $auto_declined > 0
            ? sprintf(
                /* translators: %d is the number of pending requests automatically declined */
                _n(
                    'Assignments synced. %d pending request was automatically declined as the shift is now full.',
                    'Assignments synced. %d pending requests were automatically declined as the shift is now full.',
                    $auto_declined,
                    'employee-roster'
                ),
                $auto_declined
            )
            : __( 'Assignments synced.', 'employee-roster' );

        self::ok( array( 'message' => $msg ) );
    }

    /* ── Leave ───────────────────────────────────────────────────────────── */

    /* Leave management handlers (get_leave / update_leave / delete_leave) live in
       the optional companion fragment. EMPR_Leave::query/count remain available to
       the dashboard read path in this class. */

    /* ── Leave Balances ─────────────────────────────────────────────────── */

    /* ── Swap Requests ──────────────────────────────────────────────────── */

    /* ── Attendance ──────────────────────────────────────────────────────── */

    /* ── Timesheets ──────────────────────────────────────────────────────── */

    /**
     * Returns confirmed shift records for a user in the given date range,
     * formatted as pseudo-session objects so the JS timesheet renderer can
     * reuse its existing clock_in / clock_out / break_minutes logic.
     * Used when allow_self_clock is disabled (shift-based payroll mode).
     */
    private static function shift_sessions_for_user( $uid, $start, $end ) {
        global $wpdb;

        $where  = array( 'sa.user_id = %d', "sa.status = 'confirmed'", "s.status = 'published'" );
        $values = array( (int) $uid );
        if ( $start ) { $where[] = 'DATE(s.start_datetime) >= %s'; $values[] = $start; }
        if ( $end )   { $where[] = 'DATE(s.start_datetime) <= %s'; $values[] = $end; }

        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT s.id, s.start_datetime, s.end_datetime, s.break_minutes, s.department, s.title
             FROM {$wpdb->prefix}emr_assignments sa
             INNER JOIN {$wpdb->prefix}emr_shifts s ON s.id = sa.shift_id
             WHERE " . implode( ' AND ', $where ) . "
             ORDER BY s.start_datetime ASC",
            $values
        ) );

        return array_map( function ( $s ) use ( $uid ) {
            return (object) array(
                'id'            => (int) $s->id,
                'user_id'       => (int) $uid,
                'shift_id'      => (int) $s->id,
                'clock_in'      => $s->start_datetime,
                'clock_out'     => $s->end_datetime,
                'break_minutes' => (int) $s->break_minutes,
                'status'        => 'completed',
                'notes'         => $s->department ?: ( $s->title ?: 'Shift' ),
            );
        }, $rows );
    }

    public static function get_timesheets() {
        self::verify_can_view();
        $start = ! empty( $_GET['start'] ) ? sanitize_text_field( $_GET['start'] ) : null;
        $end   = ! empty( $_GET['end'] )   ? sanitize_text_field( $_GET['end'] )   : null;

        $shift_mode    = EMPR_Settings::get( 'allow_self_clock', '1' ) !== '1';
        $users         = EMPR_Settings::get_roster_users();

        $result = array();
        foreach ( $users as $user ) {
            if ( $shift_mode ) {
                $sessions = self::shift_sessions_for_user( $user->ID, $start, $end );
            } else {
                $args = array( 'user_id' => $user->ID, 'limit' => 1000 );
                if ( $start ) $args['start'] = $start;
                if ( $end )   $args['end']   = $end;
                $sessions = EMPR_Attendance::query( $args );
            }

            $total_secs = 0;
            foreach ( $sessions as $s ) {
                if ( $s->clock_in && $s->clock_out ) {
                    $raw = ( strtotime( $s->clock_out ) - strtotime( $s->clock_in ) ) - (int) $s->break_minutes * 60;
                    if ( $raw > 0 ) $total_secs += $raw;
                }
            }

            // Leave totals: `total` counts every approved leave type (annual, sick,
            // personal, unpaid, other) for the "Leave (Days)" column; `paid` counts
            // only paid types (annual/sick) whose hours feed earnings calculations.
            $leave = array( 'total' => 0, 'paid' => 0 );

            $result[] = array(
                'user_id'          => $user->ID,
                'display_name'     => $user->display_name,
                'roles'            => $user->roles,
                'session_count'    => count( $sessions ),
                'total_hours'      => round( $total_secs / 3600, 2 ),
                'leave_days'       => $leave['total'],                                       // all approved types — display
                'paid_leave_days'  => $leave['paid'],                                        // annual/sick — pay basis
                'paid_leave_hours' => round( $leave['paid'] * self::LEAVE_HOURS_PER_DAY, 2 ), // feeds earnings
                'pay_rate'         => (float) get_user_meta( $user->ID, 'empr_pay_rate', true ),
            );
        }

        usort( $result, function ( $a, $b ) { return strcmp( $a['display_name'], $b['display_name'] ); } );
        self::ok( $result );
    }

    public static function get_timesheet_user() {
        self::verify();
        $uid   = ! empty( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
        $start = ! empty( $_GET['start'] )   ? sanitize_text_field( $_GET['start'] ) : null;
        $end   = ! empty( $_GET['end'] )     ? sanitize_text_field( $_GET['end'] )   : null;
        if ( ! $uid ) {
            wp_send_json_error( array( 'message' => __( 'user_id required.', 'employee-roster' ) ), 400 );
        }
        // Employees may only view their own timesheet
        if ( $uid !== get_current_user_id() && ! EMPR_Roles::can_view_all() ) {
            wp_send_json_error( array( 'message' => __( 'Access denied.', 'employee-roster' ) ), 403 );
        }
        if ( EMPR_Settings::get( 'allow_self_clock', '1' ) !== '1' ) {
            $sessions = self::shift_sessions_for_user( $uid, $start, $end );
        } else {
            $args = array( 'user_id' => $uid, 'limit' => 1000 );
            if ( $start ) $args['start'] = $start;
            if ( $end )   $args['end']   = $end;
            $sessions = EMPR_Attendance::query( $args );
        }

        // Paid leave entries (annual + sick, approved) in this period — only when leave tracking is available
        $leave_on   = false;
        $paid_leave = $leave_on ? self::get_paid_leave_entries( $uid, $start, $end ) : array();

        self::ok( array(
            'sessions'   => $sessions,
            'paid_leave' => $paid_leave,
        ) );
    }

    /** Paid leave types — their hours feed earnings; all other approved types count as days only. */
    private static function is_paid_leave_type( $leave_type ) {
        return in_array( $leave_type, array( 'annual', 'sick' ), true );
    }

    /**
     * Returns approved leave entries (every type — annual, sick, personal, unpaid,
     * other) for a user within a date range. Each entry carries the number of leave
     * days overlapping the period; `hours` is derived at the fixed rate of
     * LEAVE_HOURS_PER_DAY for paid types only (annual/sick) so the UI can compute
     * leave pay (days × hours-per-day × pay rate) without paying for unpaid leave.
     * Unpaid/personal/other entries report `hours => 0` but still count toward the
     * displayed day total. Display is in days.
     */
    private static function get_paid_leave_entries( $uid, $start, $end ) {
        $args = array( 'user_id' => $uid, 'status' => 'approved' );
        if ( $start ) $args['start'] = $start;
        if ( $end )   $args['end']   = $end;

        $entries = array();
        foreach ( EMPR_Leave::query( $args ) as $lr ) {
            $ls = strtotime( $lr->start_date );
            $le = strtotime( $lr->end_date );
            $cs = $start ? max( $ls, strtotime( $start ) ) : $ls;
            $ce = $end   ? min( $le, strtotime( $end ) )   : $le;
            if ( $ce < $cs ) continue;
            $days     = (int) round( ( $ce - $cs ) / 86400 ) + 1;
            $paid     = self::is_paid_leave_type( $lr->leave_type );
            $entries[] = array(
                'id'           => (int) $lr->id,
                'leave_type'   => $lr->leave_type,
                'start_date'   => $lr->start_date,
                'end_date'     => $lr->end_date,
                'period_start' => wp_date( 'Y-m-d', $cs ),
                'days'         => $days,
                'paid'         => $paid,
                'hours'        => $paid ? round( $days * self::LEAVE_HOURS_PER_DAY, 2 ) : 0,
            );
        }
        return $entries;
    }

    /**
     * Sum of approved leave DAYS for a user within a date range, split by type.
     * Used by the timesheets summary list. Counts calendar days inclusive of the
     * overlapping portion of each leave request, independent of job type.
     *
     * Returns array(
     *   'total' => all approved leave days (annual, sick, personal, unpaid, other),
     *   'paid'  => paid-type days only (annual/sick) — used as the earnings basis,
     * ).
     */
    private static function calc_leave_days( $uid, $start, $end ) {
        $args = array( 'user_id' => $uid, 'status' => 'approved' );
        if ( $start ) $args['start'] = $start;
        if ( $end )   $args['end']   = $end;

        $total = 0;
        $paid  = 0;
        foreach ( EMPR_Leave::query( $args ) as $lr ) {
            $ls   = strtotime( $lr->start_date );
            $le   = strtotime( $lr->end_date );
            $cs   = $start ? max( $ls, strtotime( $start ) ) : $ls;
            $ce   = $end   ? min( $le, strtotime( $end ) )   : $le;
            if ( $ce < $cs ) continue;
            $days   = (int) round( ( $ce - $cs ) / 86400 ) + 1;
            $total += $days;
            if ( self::is_paid_leave_type( $lr->leave_type ) ) $paid += $days;
        }
        return array( 'total' => $total, 'paid' => $paid );
    }

    /* ── Availability ────────────────────────────────────────────────────── */

    public static function get_availability() {
        self::verify();
        $uid = ( EMPR_Roles::is_manager() && ! empty( $_GET['user_id'] ) )
            ? absint( $_GET['user_id'] ) : get_current_user_id();
        self::ok( EMPR_Availability::for_user( $uid ) );
    }

    public static function save_availability() {
        self::verify();
        $body  = self::post_json();
        $uid   = ( EMPR_Roles::is_manager() && ! empty( $body['user_id'] ) )
            ? absint( $body['user_id'] ) : get_current_user_id();
        $slots = isset( $body['slots'] ) ? (array) $body['slots'] : array();
        EMPR_Availability::save( $uid, $slots );
        self::ok( array( 'message' => __( 'Availability saved.', 'employee-roster' ) ) );
    }

    public static function all_availability() {
        self::verify_can_view();
        self::ok( EMPR_Availability::all_staff() );
    }

    /* ── Profile (self) ─────────────────────────────────────────────────── */

    public static function get_profile() {
        self::verify();
        $uid  = get_current_user_id();
        $user = get_userdata( $uid );
        self::ok( array(
            'id'           => $uid,
            'first_name'   => get_user_meta( $uid, 'first_name', true ),
            'last_name'    => get_user_meta( $uid, 'last_name',  true ),
            'display_name' => $user->display_name,
            'email'        => $user->user_email,
            'roles'        => self::display_roles( $user ),
            'is_wp_admin'  => user_can( $uid, 'manage_options' ),
            'pay_rate'     => (float) get_user_meta( $uid, 'empr_pay_rate', true ),
            'job_type'     => self::get_user_job_type( $uid ),
            'skills'       => self::get_user_skills( $uid ),
        ) );
    }

    public static function update_profile() {
        self::verify();
        $body  = self::post_json();
        $uid   = get_current_user_id();
        $first = sanitize_text_field( isset( $body['first_name'] ) ? $body['first_name'] : '' );
        $last  = sanitize_text_field( isset( $body['last_name'] )  ? $body['last_name']  : '' );
        $email = sanitize_email( isset( $body['email'] ) ? $body['email'] : '' );

        $display = trim( $first . ' ' . $last );
        $update  = array(
            'ID'           => $uid,
            'first_name'   => $first,
            'last_name'    => $last,
            'display_name' => $display ?: get_userdata( $uid )->display_name,
        );
        if ( $email && is_email( $email ) ) {
            $update['user_email'] = $email;
        }
        $result = wp_update_user( $update );
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 );
        }
        self::ok( array( 'message' => __( 'Profile updated.', 'employee-roster' ) ) );
    }

    public static function change_password() {
        self::verify();
        $body    = self::post_json();
        $current = isset( $body['current_password'] ) ? $body['current_password'] : '';
        $new_pw  = isset( $body['new_password'] )     ? $body['new_password']     : '';
        $confirm = isset( $body['confirm_password'] )  ? $body['confirm_password']  : '';

        if ( empty( $current ) || empty( $new_pw ) || empty( $confirm ) ) {
            wp_send_json_error( array( 'message' => __( 'All password fields are required.', 'employee-roster' ) ), 400 );
        }
        if ( $new_pw !== $confirm ) {
            wp_send_json_error( array( 'message' => __( 'New passwords do not match.', 'employee-roster' ) ), 400 );
        }
        if ( strlen( $new_pw ) < 8 ) {
            wp_send_json_error( array( 'message' => __( 'New password must be at least 8 characters.', 'employee-roster' ) ), 400 );
        }

        $uid  = get_current_user_id();
        $user = get_userdata( $uid );
        if ( ! wp_check_password( $current, $user->user_pass, $uid ) ) {
            wp_send_json_error( array( 'message' => __( 'Current password is incorrect.', 'employee-roster' ) ), 400 );
        }

        // Change the password and immediately re-issue the auth cookie so the
        // browser has a valid session for the page reload that follows.
        wp_set_password( $new_pw, $uid );
        wp_set_auth_cookie( $uid, true );
        self::ok( array( 'message' => __( 'Password updated successfully.', 'employee-roster' ) ) );
    }

    /* ── Staff ───────────────────────────────────────────────────────────── */

    /** Returns the stored job type for a user, defaulting to 'casual'. */
    private static function get_user_job_type( $uid ) {
        $jt = get_user_meta( $uid, 'empr_job_type', true );
        if ( $jt ) return $jt;
        return 'casual';
    }

    private static function save_user_job_type( $uid, $job_type ) {
        $allowed = array( 'fulltime', 'parttime', 'casual' );
        if ( ! in_array( $job_type, $allowed, true ) ) return;
        update_user_meta( $uid, 'empr_job_type', $job_type );
    }

    private static function get_user_skills( $uid ) {
        $raw = get_user_meta( $uid, 'empr_skills', true );
        if ( ! $raw ) return array();
        $decoded = json_decode( $raw, true );
        return is_array( $decoded ) ? $decoded : array();
    }

    /** Returns the roster-display roles for a user. */
    private static function display_roles( $user ) {
        $emr = array_values( array_intersect( (array) $user->roles, array( 'emr_employee', 'emr_supervisor', 'emr_manager' ) ) );
        if ( ! empty( $emr ) ) return $emr;
        return (array) $user->roles;
    }

    public static function get_staff() {
        self::verify_can_view();
        $users  = EMPR_Settings::get_roster_users();
        $result = array();
        foreach ( $users as $u ) {
            $is_wp_admin = user_can( $u->ID, 'manage_options' );
            $entry = array(
                'id'           => $u->ID,
                'first_name'   => get_user_meta( $u->ID, 'first_name', true ),
                'last_name'    => get_user_meta( $u->ID, 'last_name',  true ),
                'display_name' => $u->display_name,
                'email'        => $u->user_email,
                'roles'        => self::display_roles( $u ),
                'is_wp_admin'  => $is_wp_admin,
                'pay_rate'     => (float) get_user_meta( $u->ID, 'empr_pay_rate', true ),
                'job_type'     => self::get_user_job_type( $u->ID ),
                'skills'       => self::get_user_skills( $u->ID ),
            );
            $result[] = $entry;
        }
        self::ok( $result );
    }

    public static function create_staff() {
        self::verify_manager();
        $body  = self::post_json();
        $first = sanitize_text_field( isset( $body['first_name'] ) ? $body['first_name'] : '' );
        $last  = sanitize_text_field( isset( $body['last_name'] )  ? $body['last_name']  : '' );
        $email = sanitize_email( isset( $body['email'] )    ? $body['email']    : '' );

        if ( ! $first ) {
            wp_send_json_error( array( 'message' => __( 'First name is required.', 'employee-roster' ) ), 400 );
        }
        if ( ! $email || ! is_email( $email ) ) {
            wp_send_json_error( array( 'message' => __( 'A valid email address is required.', 'employee-roster' ) ), 400 );
        }

        $pass = wp_generate_password( 12, true, false );

        // Generate a unique username from the email local-part
        $base = sanitize_user( strstr( $email, '@', true ), true );
        if ( ! $base ) $base = 'user';
        $username = $base;
        $i = 1;
        while ( username_exists( $username ) ) {
            $username = $base . $i++;
        }

        $user_id = wp_create_user( $username, $pass, $email );
        if ( is_wp_error( $user_id ) ) {
            wp_send_json_error( array( 'message' => $user_id->get_error_message() ), 400 );
        }

        $display = trim( $first . ' ' . $last );
        wp_update_user( array(
            'ID'           => $user_id,
            'first_name'   => $first,
            'last_name'    => $last,
            'display_name' => $display ?: $username,
            'role'         => 'emr_employee',
        ) );

        // Save job type; employees default to casual.
        if ( ! empty( $body['job_type'] ) ) {
            self::save_user_job_type( $user_id, $body['job_type'] );
        } else {
            update_user_meta( $user_id, 'empr_job_type', 'casual' );
        }

        // Send welcome email (simplified in this build; no credentials)
        if ( class_exists( 'EMPR_Notifications' ) ) {
            EMPR_Notifications::welcome( $user_id, $pass );
        }

        self::ok( array( 'id' => $user_id, 'message' => __( 'Staff member created.', 'employee-roster' ), 'password' => $pass ) );
    }

    public static function update_staff() {
        self::verify_manager();
        $body    = self::post_json();
        $user_id = absint( isset( $body['id'] ) ? $body['id'] : 0 );

        if ( ! $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid user ID.', 'employee-roster' ) ), 400 );
        }
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            wp_send_json_error( array( 'message' => __( 'User not found.', 'employee-roster' ) ), 404 );
        }
        // Allow roster-role users and WP admins who are included in the roster.
        $roster_roles = array_intersect( $user->roles, array( 'emr_employee', 'emr_supervisor', 'emr_manager' ) );
        $is_wp_admin  = user_can( $user_id, 'manage_options' );
        if ( empty( $roster_roles ) && ! $is_wp_admin ) {
            wp_send_json_error( array( 'message' => __( 'Cannot edit this user through the roster.', 'employee-roster' ) ), 403 );
        }

        $email = sanitize_email( isset( $body['email'] ) ? $body['email'] : '' );
        $first = sanitize_text_field( isset( $body['first_name'] ) ? $body['first_name'] : '' );
        $last  = sanitize_text_field( isset( $body['last_name'] )  ? $body['last_name']  : '' );

        $display = trim( $first . ' ' . $last );
        $update  = array(
            'ID'           => $user_id,
            'first_name'   => $first,
            'last_name'    => $last,
            'display_name' => $display ?: $user->display_name,
        );
        // Never allow changing a WP administrator's email through
        // the roster. Email changes can enable account takeover via
        // password-reset flows; admins must change their own email
        // through their WordPress profile, not via roster management.
        if ( $email && is_email( $email ) && ! $is_wp_admin ) {
            $update['user_email'] = $email;
        }
        wp_update_user( $update );

        if ( isset( $body['job_type'] ) ) {
            self::save_user_job_type( $user_id, $body['job_type'] );
        }

        self::ok( array( 'message' => __( 'Staff member updated.', 'employee-roster' ) ) );
    }

    public static function deactivate_staff() {
        self::verify_manager();
        $body    = self::post_json();
        $user_id = absint( isset( $body['user_id'] ) ? $body['user_id'] : 0 );

        if ( ! $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid user ID.', 'employee-roster' ) ), 400 );
        }
        if ( $user_id === get_current_user_id() ) {
            wp_send_json_error( array( 'message' => __( 'You cannot deactivate your own account.', 'employee-roster' ) ), 400 );
        }

        $user = get_userdata( $user_id );
        if ( ! $user ) {
            wp_send_json_error( array( 'message' => __( 'User not found.', 'employee-roster' ) ), 404 );
        }
        if ( user_can( $user_id, 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Cannot deactivate a site administrator.', 'employee-roster' ) ), 403 );
        }
        $roster_roles = array_intersect( $user->roles, array( 'emr_employee', 'emr_supervisor', 'emr_manager' ) );
        if ( empty( $roster_roles ) ) {
            wp_send_json_error( array( 'message' => __( 'Cannot deactivate this user.', 'employee-roster' ) ), 403 );
        }

        update_user_meta( $user_id, 'empr_deactivated', '1' );

        global $wpdb;

        // Cancel pending leave and swap requests.
        $wpdb->delete(
            EMPR_DB::table( 'leave' ),
            array( 'user_id' => $user_id, 'status' => 'pending' ),
            array( '%d', '%s' )
        );
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->prefix}emr_swap_requests WHERE status = 'pending' AND (requester_id = %d OR target_user_id = %d)",
            $user_id, $user_id
        ) );

        // Delete all today + future shifts for the deactivated user.
        // Shifts shared with other active staff only lose this user's assignment row;
        // shifts where this user is the sole assignee are deleted entirely.
        $today         = current_time( 'Y-m-d' ) . ' 00:00:00';
        $ta            = EMPR_DB::table( 'assignments' );
        $future_shifts = $wpdb->get_col( $wpdb->prepare(
            "SELECT a.shift_id FROM {$wpdb->prefix}emr_assignments a
             INNER JOIN {$wpdb->prefix}emr_shifts s ON s.id = a.shift_id
             WHERE a.user_id = %d AND a.status = 'confirmed'
               AND s.status != 'archived'
               AND s.start_datetime >= %s",
            $user_id, $today
        ) );
        foreach ( $future_shifts as $shift_id ) {
            $shift_id    = (int) $shift_id;
            $other_count = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}emr_assignments WHERE shift_id = %d AND status = 'confirmed' AND user_id != %d",
                $shift_id, $user_id
            ) );
            if ( $other_count === 0 ) {
                EMPR_Shifts::delete( $shift_id );
            } else {
                $wpdb->delete( $ta, array( 'shift_id' => $shift_id, 'user_id' => $user_id ), array( '%d', '%d' ) );
            }
        }

        self::ok( array( 'message' => __( 'Staff member deactivated.', 'employee-roster' ) ) );
    }

    public static function get_deactivated_staff() {
        self::verify_manager();
        $users = get_users( array(
            'role__in'   => array( 'emr_employee', 'emr_supervisor', 'emr_manager' ),
            'meta_key'   => 'empr_deactivated',
            'meta_value' => '1',
        ) );
        $result = array();
        foreach ( $users as $u ) {
            $result[] = array(
                'id'           => $u->ID,
                'display_name' => $u->display_name,
                'email'        => $u->user_email,
                'roles'        => $u->roles,
                'job_type'     => self::get_user_job_type( $u->ID ),
            );
        }
        self::ok( $result );
    }

    public static function reactivate_staff() {
        self::verify_manager();
        $body    = self::post_json();
        $user_id = absint( isset( $body['user_id'] ) ? $body['user_id'] : 0 );

        if ( ! $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid user ID.', 'employee-roster' ) ), 400 );
        }
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            wp_send_json_error( array( 'message' => __( 'User not found.', 'employee-roster' ) ), 404 );
        }
        if ( get_user_meta( $user_id, 'empr_deactivated', true ) !== '1' ) {
            wp_send_json_error( array( 'message' => __( 'This user is not deactivated.', 'employee-roster' ) ), 400 );
        }

        // Never allow resetting passwords of WP administrators.
        // Prevents roster managers from locking out site admins.
        if ( user_can( $user, 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Cannot reset password for this account.', 'employee-roster' ) ), 403 );
        }

        // Only allow resetting passwords of roster role users.
        // Prevents managers from affecting unrelated WP accounts.
        $roster_roles   = array( 'emr_employee', 'emr_supervisor', 'emr_manager' );
        $target_roles   = (array) $user->roles;
        $is_roster_user = ! empty( array_intersect( $roster_roles, $target_roles ) );

        if ( ! $is_roster_user ) {
            wp_send_json_error( array( 'message' => __( 'Cannot reset password for this account.', 'employee-roster' ) ), 403 );
        }

        delete_user_meta( $user_id, 'empr_deactivated' );

        $pass = wp_generate_password( 12, true, false );
        wp_set_password( $pass, $user_id );

        if ( class_exists( 'EMPR_Notifications' ) ) {
            EMPR_Notifications::welcome( $user_id, $pass );
        }

        self::ok( array(
            'message'  => __( 'Staff member reactivated.', 'employee-roster' ),
            'password' => $pass,
        ) );
    }

    /* ── Reset Account ───────────────────────────────────────────────────── */

    /* ── Copy Roster ─────────────────────────────────────────────────────── */

    /* ── Job Sheet Attachments ───────────────────────────────────────────── */

    /**
     * WP-Cron callback — delete job sheet attachments older than the configured
     * auto-delete window. Each attachment's upload date (post_date) is used.
     */
    public static function cron_job_sheet_cleanup() {
        $policy = EMPR_Settings::get( 'job_sheet_auto_delete', 'never' );
        if ( 'never' === $policy ) {
            return;
        }
        $days_map = array(
            '1month'   => 30,
            '3months'  => 90,
            '6months'  => 180,
            '12months' => 365,
        );
        if ( ! isset( $days_map[ $policy ] ) ) {
            return;
        }
        $cutoff_ts = time() - ( $days_map[ $policy ] * DAY_IN_SECONDS );
        global $wpdb;

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is a hardcoded {$wpdb->prefix}emr_* constant; no user input.
        $shifts = $wpdb->get_results( "SELECT id, attachment_id FROM {$wpdb->prefix}emr_shifts WHERE attachment_id IS NOT NULL" );
        foreach ( $shifts as $shift ) {
            $att_id = (int) $shift->attachment_id;
            if ( ! $att_id ) continue;
            $att_post = get_post( $att_id );
            if ( ! $att_post ) {
                // Attachment already gone — clear the stale reference
                $wpdb->query( $wpdb->prepare(
                    "UPDATE {$wpdb->prefix}emr_shifts SET attachment_id = NULL WHERE id = %d",
                    (int) $shift->id
                ) );
                continue;
            }
            if ( (int) strtotime( $att_post->post_date ) <= $cutoff_ts ) {
                wp_delete_attachment( $att_id, true );
                $wpdb->query( $wpdb->prepare(
                    "UPDATE {$wpdb->prefix}emr_shifts SET attachment_id = NULL WHERE id = %d",
                    (int) $shift->id
                ) );
            }
        }
    }

    /* ── Dashboard ───────────────────────────────────────────────────────── */

    public static function get_dashboard() {
        self::verify();
        $uid      = get_current_user_id();
        $today    = current_time( 'Y-m-d' );
        $dow      = (int) current_time( 'N' );
        $week_mon = wp_date( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . ( $dow - 1 ) . ' days' ) );
        $week_sun = wp_date( 'Y-m-d', strtotime( $week_mon . ' +6 days' ) );

        // Attendance session data is supplied by the optional dashboard_attendance()
        // seam when it is available. Without it the dashboard reports no active
        // session and zero hours, which is the correct result for a build that does
        // not track clock sessions.
        $session = null;
        $hours   = 0;
        if ( method_exists( __CLASS__, 'dashboard_attendance' ) ) {
            $att     = self::dashboard_attendance( $uid, $week_mon, $week_sun );
            $session = $att['session'];
            $hours   = $att['hours'];
        }
        $elapsed  = null;
        if ( $session && $session->clock_in ) {
            $elapsed = max( 0, (int) current_time( 'timestamp' ) - (int) strtotime( $session->clock_in ) );
        }

        $payload = array(
            'my_upcoming_shifts' => EMPR_Shifts::query( array(
                'user_id' => $uid,
                'start'   => $today . ' 00:00:00',
                'end'     => $week_sun . ' 23:59:59',
            ) ),
            'hours_this_week'    => $hours,
            'clocked_in'         => (bool) $session,
            'session'            => $session,
            'elapsed_seconds'    => $elapsed,
            'pending_leave_own'  => EMPR_Settings::get( 'leave_enabled', '1' ) === '1'
                                        ? count( EMPR_Leave::query( array( 'user_id' => $uid, 'status' => 'pending' ) ) )
                                        : 0,
        );

        if ( EMPR_Roles::can_view_all() ) {
            $payload['pending_leave_all']    = EMPR_Settings::get( 'leave_enabled', '1' ) === '1'
                                                ? count( EMPR_Leave::query( array( 'status' => 'pending' ) ) )
                                                : 0;
            global $wpdb;
            $payload['shifts_now'] = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}emr_shifts s
                 WHERE s.status = %s
                   AND s.start_datetime >= %s
                   AND (
                       NOT EXISTS (
                           SELECT 1 FROM {$wpdb->prefix}emr_assignments a WHERE a.shift_id = s.id AND a.status = 'confirmed'
                       )
                       OR EXISTS (
                           SELECT 1 FROM {$wpdb->prefix}emr_assignments a
                           LEFT JOIN {$wpdb->usermeta} um
                               ON um.user_id = a.user_id AND um.meta_key = 'empr_deactivated'
                           WHERE a.shift_id = s.id AND a.status = 'confirmed'
                             AND (um.meta_value IS NULL OR um.meta_value != '1')
                       )
                   )",
                'published', $today . ' 00:00:00'
            ) );
            $payload['shifts_today']         = count( EMPR_Shifts::query( array(
                'start'  => $today . ' 00:00:00',
                'end'    => $today . ' 23:59:59',
                'status' => 'published',
            ) ) );
            $payload['total_staff']          = count( EMPR_Settings::get_roster_users( array( 'fields' => 'ID' ) ) );
            if ( EMPR_Roles::is_manager() ) {
                // Approval actions only for managers
                $payload['open_shift_requests'] = EMPR_Assignments::pending_requests();
                $payload['pending_swaps']         = 0;
                $payload['pending_swap_requests'] = array();
            } else {
                $payload['pending_swaps'] = 0;
            }
        }

        self::ok( $payload );
    }

    /* ── Open Shifts ─────────────────────────────────────────────────────── */

    /* ── Settings ────────────────────────────────────────────────────────── */

    public static function get_settings() {
        self::verify();
        self::ok( array_merge(
            EMPR_Settings::for_js(),
            array(
                'isManager'   => EMPR_Roles::is_manager(),
                'isAdmin'     => EMPR_Roles::is_admin(),
            )
        ) );
    }

    /* ── Export ──────────────────────────────────────────────────────────── */

    /* ── Login Logs ─────────────────────────────────────────────────────────── */

    /** Cron: auto-delete old log entries based on retention setting. */
    public static function cron_login_logs_cleanup() {
        $retention = EMPR_Settings::get( 'login_logs_retention', '90' );
        if ( 'never' === $retention || ! is_numeric( $retention ) ) return;
        EMPR_LoginLogs::delete_old( (int) $retention );
    }
}
