<?php
/**
 * AJAX Handler
 *
 * @package Freelance_Studio
 */

class FS_Ajax_Handler {

    /**
     * Verify AJAX nonce
     */
    private function verify_nonce() {
        if (!check_ajax_referer('fs_ajax_nonce', 'nonce', false)) {
            wp_send_json_error(array('message' => __('Security check failed', 'freelance-studio')));
            exit;
        }
    }

    /**
     * Verify user has required capability
     * 
     * @param string $capability Required capability (default: edit_posts)
     */
    private function verify_capability($capability = 'edit_posts') {
        if (!current_user_can($capability)) {
            FS_Security_Service::log_security_event(
                'unauthorized_access',
                'User attempted to access AJAX endpoint without required capability',
                array('capability' => $capability, 'user_id' => get_current_user_id())
            );
            wp_send_json_error(array('message' => __('You do not have permission to perform this action.', 'freelance-studio')));
            exit;
        }
    }

    /**
     * Get client data
     */
    public function get_client() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid client ID', 'freelance-studio')));
        }
        
        $client = FS_Client::get($id);
        
        if (!$client) {
            wp_send_json_error(array('message' => __('Client not found', 'freelance-studio')));
        }
        
        wp_send_json_success($client);
    }

    /**
     * Get task data
     */
    public function get_task() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid task ID', 'freelance-studio')));
        }
        
        $task = FS_Task::get($id);
        
        if (!$task) {
            wp_send_json_error(array('message' => __('Task not found', 'freelance-studio')));
        }
        
        wp_send_json_success($task);
    }

    /**
     * Get tasks for a project (used in time entry form)
     */
    public function get_project_tasks() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_success(array());
            return;
        }
        
        $tasks = FS_Task::get_all(array(
            'project_id' => $project_id,
            'orderby' => 'title',
            'order' => 'ASC',
            'exclude_recurring_children' => true,
        ));
        
        // Return id, title, and non-billable flag
        $task_data = array();
        foreach ($tasks as $task) {
            $task_data[] = array(
                'id' => $task->id,
                'title' => $task->title,
                'is_non_billable' => !empty($task->is_non_billable) ? 1 : 0,
            );
        }
        
        wp_send_json_success($task_data);
    }

    /**
     * Delete client
     */
    public function delete_client() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid client ID', 'freelance-studio')));
        }
        
        // Check for related records
        $projects = FS_Project::count(array('client_id' => $id));
        $invoices = FS_Invoice::count(array('client_id' => $id));
        
        if ($projects > 0 || $invoices > 0) {
            wp_send_json_error(array(
                'message' => sprintf(
                    /* translators: 1. project count, 2. invoice count */
                    __('Cannot delete client. They have %1$d projects and %2$d invoices.', 'freelance-studio'),
                    $projects,
                    $invoices
                )
            ));
        }
        
        $result = FS_Client::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Client deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete client', 'freelance-studio')));
        }
    }

    /**
     * Delete project
     */
    public function delete_project() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        // Check for related invoices
        $invoices = FS_Invoice::count(array('project_id' => $id));
        
        if ($invoices > 0) {
            wp_send_json_error(array(
                'message' => sprintf(
                    /* translators: 1. project count */
                    __('Cannot delete project. It has %d invoices.', 'freelance-studio'),
                    $invoices
                )
            ));
        }
        
        $result = FS_Project::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Project deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete project', 'freelance-studio')));
        }
    }

    /**
     * Bulk archive or restore projects
     *
     * @since 8.5.3
     */
    public function bulk_archive_projects() {
        $this->verify_nonce();
        
        $ids = isset($_POST['project_ids']) ? array_map('intval', (array) $_POST['project_ids']) : array();
        $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field(wp_unslash($_POST['bulk_action'])) : '';
        
        if (empty($ids) || !in_array($bulk_action, array('archive', 'restore'), true)) {
            wp_send_json_error(__('Invalid request', 'freelance-studio'));
        }
        
        $count = 0;
        foreach ($ids as $id) {
            if ($id > 0) {
                if ($bulk_action === 'archive') {
                    FS_Project::archive($id);
                } else {
                    FS_Project::unarchive($id);
                }
                $count++;
            }
        }
        
        wp_send_json_success(array(
            'message' => sprintf(
                /* translators: 1. count of projects */
                $bulk_action === 'archive'
                    ? __('%d projects archived', 'freelance-studio')
                    : __('%d projects restored', 'freelance-studio'),
                $count
            ),
            'count' => $count,
        ));
    }

    /**
     * Delete invoice
     */
    public function delete_invoice() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid invoice ID', 'freelance-studio')));
        }
        
        $invoice = FS_Invoice::get($id);
        
        if (!$invoice) {
            wp_send_json_error(array('message' => __('Invoice not found', 'freelance-studio')));
        }
        
        // Don't allow deleting paid invoices
        if ($invoice->status === 'paid') {
            wp_send_json_error(array('message' => __('Cannot delete a paid invoice', 'freelance-studio')));
        }
        
        $result = FS_Invoice::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Invoice deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete invoice', 'freelance-studio')));
        }
    }

    /**
     * Delete expense
     */
    public function delete_expense() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid expense ID', 'freelance-studio')));
        }
        
        $result = FS_Expense::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Expense deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete expense', 'freelance-studio')));
        }
    }

    /**
     * Delete annual deduction
     */
    public function delete_annual_deduction() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid deduction ID', 'freelance-studio')));
        }
        
        $result = FS_Annual_Deduction::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Deduction deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete deduction', 'freelance-studio')));
        }
    }

    /**
     * Generate invoice from project
     */
    public function generate_invoice_from_project() {
        $this->verify_nonce();
        
        $project_id        = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $include_time      = isset($_POST['include_time']) && $_POST['include_time'] === '1';
        $include_retainer  = isset($_POST['include_retainer']) && $_POST['include_retainer'] === '1';
        $retainer_period_id = isset($_POST['retainer_period_id']) ? intval($_POST['retainer_period_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }

        $items = array();

        // --- Retainer billing ---
        $retainer_period = null;
        if ($include_retainer && !empty($project->is_retainer) && $retainer_period_id) {
            $retainer_period = FS_Retainer_Period::get($retainer_period_id);
            if ($retainer_period && !$retainer_period->invoiced) {
                $period_label = date_i18n('F Y', strtotime($retainer_period->period_start));
                $items[] = array(
                    'description' => sprintf(
                        /* translators: 1. date/time */
                        __('Monthly Retainer — %s', 'freelance-studio'),
                        $period_label
                    ),
                    'quantity' => 1,
                    'rate'     => floatval($retainer_period->amount),
                    'amount'   => floatval($retainer_period->amount),
                );
            }
        }

        // --- Services (non-retainer projects only, or retainer projects that also want to bill services) ---
        if (!$include_retainer || empty($project->is_retainer)) {
            $services = $project->services;
            if (is_string($services)) {
                $services = json_decode($services, true);
            }

            if (!empty($services) && is_array($services)) {
                foreach ($services as $svc) {
                    $name         = isset($svc['name']) ? $svc['name'] : __('Service', 'freelance-studio');
                    $pricing_type = isset($svc['pricing_type']) ? $svc['pricing_type'] : 'flat';
                    $rate         = isset($svc['rate']) ? floatval($svc['rate']) : 0;
                    $quantity     = isset($svc['quantity']) ? floatval($svc['quantity']) : 1;

                    if ($pricing_type === 'hourly' && $include_time) {
                        continue;
                    }

                    if ($pricing_type === 'hourly') {
                        $amount      = $rate * $quantity;
                        $description = $name . ' (' . $quantity . ' ' . __('hours estimated', 'freelance-studio') . ')';
                    } else {
                        $amount      = isset($svc['amount']) ? floatval($svc['amount']) : ($rate * $quantity);
                        $description = $name;
                    }

                    if ($amount > 0) {
                        $items[] = array(
                            'description' => $description,
                            'quantity'    => $quantity,
                            'rate'        => $rate,
                            'amount'      => $amount,
                        );
                    }
                }
            }
        }
        
        // --- Tracked time (overage or standard unbilled time) ---
        $time_entry_ids = array();
        if ($include_time) {
            $unbilled_entries = FS_Time_Entry::get_all(array(
                'project_id' => $project_id,
                'billable'   => true,
                'invoiced'   => false,
                'running'    => false,
            ));
            
            if (!empty($unbilled_entries)) {
                $services = $project->services;
                if (is_string($services)) {
                    $services = json_decode($services, true);
                }

                $hourly_rate = get_option('fs_default_hourly_rate', 75);
                if (!empty($services) && is_array($services)) {
                    foreach ($services as $svc) {
                        if (isset($svc['pricing_type']) && $svc['pricing_type'] === 'hourly' && !empty($svc['rate'])) {
                            $hourly_rate = floatval($svc['rate']);
                            break;
                        }
                    }
                }
                
                $grouped = array();
                foreach ($unbilled_entries as $entry) {
                    $activity = $entry->activity ?: 'other';
                    if (!isset($grouped[$activity])) {
                        $grouped[$activity] = array('duration' => 0, 'entries' => array());
                    }
                    $grouped[$activity]['duration'] += $entry->duration;
                    $grouped[$activity]['entries'][] = $entry->id;
                    $time_entry_ids[] = $entry->id;
                }
                
                $activity_types = FS_Time_Entry::get_activity_types();
                
                $capped_service   = FS_Project::get_capped_hourly_service($project_id);
                $hour_cap         = ($capped_service && floatval($capped_service['hour_cap']) > 0) ? floatval($capped_service['hour_cap']) : 0;
                $cap_overage_rate = 0;
                if ($hour_cap > 0) {
                    $cap_overage_rate = floatval($capped_service['hour_cap_overage_rate'] ?? 0);
                    if ($cap_overage_rate <= 0) {
                        $cap_overage_rate = $hourly_rate;
                    }
                }
                
                $total_hours_all = 0;
                foreach ($grouped as $activity => $data) {
                    $total_hours_all += FS_Time_Entry::get_rounded_hours($data['duration']);
                }
                
                if ($hour_cap > 0 && $total_hours_all > $hour_cap) {
                    $capped_hours_remaining = $hour_cap;
                    
                    foreach ($grouped as $activity => $data) {
                        $hours         = FS_Time_Entry::get_rounded_hours($data['duration']);
                        $activity_label = $activity_types[$activity] ?? ucfirst($activity);
                        
                        if ($capped_hours_remaining > 0) {
                            $standard_hours = min($hours, $capped_hours_remaining);
                            $amount         = $standard_hours * $hourly_rate;
                            
                            $items[] = array(
                                'description' => $activity_label . ' - ' . number_format($standard_hours, 2) . ' ' . __('hours', 'freelance-studio'),
                                'quantity'    => round($standard_hours, 2),
                                'rate'        => $hourly_rate,
                                'amount'      => round($amount, 2),
                            );
                            
                            $capped_hours_remaining -= $standard_hours;
                            $overage_hours           = $hours - $standard_hours;
                        } else {
                            $overage_hours = $hours;
                        }
                        
                        if ($overage_hours > 0) {
                            $overage_amount = $overage_hours * $cap_overage_rate;
                            
                            $items[] = array(
                                'description' => $activity_label . ' (' . __('overage', 'freelance-studio') . ') - ' . number_format($overage_hours, 2) . ' ' . __('hours', 'freelance-studio'),
                                'quantity'    => round($overage_hours, 2),
                                'rate'        => $cap_overage_rate,
                                'amount'      => round($overage_amount, 2),
                            );
                        }
                    }
                } else {
                    foreach ($grouped as $activity => $data) {
                        $hours  = FS_Time_Entry::get_rounded_hours($data['duration']);
                        $amount = $hours * $hourly_rate;
                        
                        $items[] = array(
                            'description' => ($activity_types[$activity] ?? ucfirst($activity)) . ' - ' . number_format($hours, 2) . ' ' . __('hours', 'freelance-studio'),
                            'quantity'    => round($hours, 2),
                            'rate'        => $hourly_rate,
                            'amount'      => round($amount, 2),
                        );
                    }
                }
            }
        }
        
        // If no items, return error
        if (empty($items)) {
            wp_send_json_error(array('message' => __('No billable items found for this project', 'freelance-studio')));
        }
        
        $subtotal = array_sum(array_column($items, 'amount'));
        
        // Use client-specific payment terms if available
        $qi_payment_terms = get_option('fs_default_payment_terms', 30);
        if ($project->client_id) {
            $qi_client = FS_Client::get($project->client_id);
            if ($qi_client && isset($qi_client->default_payment_terms) && $qi_client->default_payment_terms > 0) {
                $qi_payment_terms = intval($qi_client->default_payment_terms);
            }
        }
        
        $invoice_data = array(
            'client_id' => $project->client_id,
            'project_id' => $project_id,
            'invoice_number' => FS_Invoice::generate_invoice_number(),
            'invoice_date' => current_time('Y-m-d'),
            'due_date' => gmdate('Y-m-d', strtotime('+' . $qi_payment_terms . ' days')),
            'status' => 'draft',
            'subtotal' => $subtotal,
            'tax_rate' => 0,
            'notes' => '',
            'items' => $items,
        );
        
        $invoice_id = FS_Invoice::create($invoice_data);
        
        if ($invoice_id) {
            // Mark time entries as invoiced if included
            if (!empty($time_entry_ids)) {
                foreach ($time_entry_ids as $entry_id) {
                    FS_Time_Entry::update($entry_id, array(
                        'invoiced' => 1,
                        'invoice_id' => $invoice_id,
                    ));
                }
            }

            // Mark retainer period as invoiced if included
            if ($retainer_period && $include_retainer) {
                FS_Retainer_Period::update($retainer_period->id, array(
                    'invoiced'   => 1,
                    'invoice_id' => $invoice_id,
                ));

                // Also mark billable time entries within this retainer period as invoiced
                // so they don't keep showing as "unbilled" in time tracking
                $retainer_time_entries = FS_Time_Entry::get_all(array(
                    'project_id' => $project_id,
                    'billable'   => true,
                    'invoiced'   => false,
                    'running'    => false,
                    'date_from'  => $retainer_period->period_start,
                    'date_to'    => $retainer_period->period_end,
                ));
                if (!empty($retainer_time_entries)) {
                    foreach ($retainer_time_entries as $rt_entry) {
                        FS_Time_Entry::update($rt_entry->id, array(
                            'invoiced'   => 1,
                            'invoice_id' => $invoice_id,
                        ));
                    }
                }
            }
            
            wp_send_json_success(array(
                'message' => __('Invoice created successfully', 'freelance-studio'),
                'invoice_id' => $invoice_id,
                'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=edit&id=' . $invoice_id)
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to create invoice', 'freelance-studio')));
        }
    }

    /**
     * Upload receipt for expense
     */
    public function upload_receipt() {
        $this->verify_nonce();
        
        $expense_id = isset($_POST['expense_id']) ? intval($_POST['expense_id']) : 0;
        
        if (!$expense_id) {
            wp_send_json_error(array('message' => __('Invalid expense ID', 'freelance-studio')));
        }
        
        if (!isset($_FILES['receipt']) || $_FILES['receipt']['error'] !== UPLOAD_ERR_OK) {
            wp_send_json_error(array('message' => __('No file uploaded or upload error', 'freelance-studio')));
        }
        
        // Check file type
        $allowed_types = array('image/jpeg', 'image/png', 'image/gif', 'application/pdf');
        if (!in_array($_FILES['receipt']['type'], $allowed_types)) {
            wp_send_json_error(array('message' => __('Invalid file type. Please upload an image or PDF.', 'freelance-studio')));
        }
        
        // Handle upload using WordPress functions
        require_once(ABSPATH . 'wp-admin/includes/file.php');
        require_once(ABSPATH . 'wp-admin/includes/media.php');
        require_once(ABSPATH . 'wp-admin/includes/image.php');
        
        $attachment_id = media_handle_upload('receipt', 0);
        
        if (is_wp_error($attachment_id)) {
            wp_send_json_error(array('message' => $attachment_id->get_error_message()));
        }
        
        // Get attachment details
        $attachment = get_post($attachment_id);
        $file_path = get_attached_file($attachment_id);
        $file_url = wp_get_attachment_url($attachment_id);
        
        // Update expense with receipt info
        $result = FS_Expense::update_receipt($expense_id, array(
            'attachment_id' => $attachment_id,
            'filename' => basename($file_path),
            'file_path' => $file_path,
            'file_url' => $file_url,
            'mime_type' => $attachment->post_mime_type,
            'file_size' => filesize($file_path),
        ));
        
        if ($result) {
            wp_send_json_success(array(
                'message' => __('Receipt uploaded successfully', 'freelance-studio'),
                'receipt_url' => $file_url,
                'filename' => basename($file_path)
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to save receipt', 'freelance-studio')));
        }
    }

    /**
     * Remove receipt from expense
     */
    public function remove_receipt() {
        $this->verify_nonce();
        
        $expense_id = isset($_POST['expense_id']) ? intval($_POST['expense_id']) : 0;
        
        if (!$expense_id) {
            wp_send_json_error(array('message' => __('Invalid expense ID', 'freelance-studio')));
        }
        
        // Get receipt info before removing
        $receipt_info = FS_Expense::get_receipt_info($expense_id);
        
        if ($receipt_info && $receipt_info['attachment_id']) {
            // Optionally delete the attachment
            // wp_delete_attachment($receipt_info['attachment_id'], true);
        }
        
        $result = FS_Expense::remove_receipt($expense_id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Receipt removed successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to remove receipt', 'freelance-studio')));
        }
    }

    /**
     * Get projects for a client (used for cascading dropdowns)
     */
    public function get_client_projects() {
        $this->verify_nonce();
        
        $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
        
        $projects = FS_Project::get_for_dropdown($client_id);
        
        wp_send_json_success($projects);
    }

    /**
     * Save contact (add or update)
     */
    public function save_contact() {
        $this->verify_nonce();
        
        $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
        $contact_id = isset($_POST['contact_id']) ? intval($_POST['contact_id']) : 0;
        
        if (!$client_id) {
            wp_send_json_error(array('message' => __('Invalid client ID', 'freelance-studio')));
        }
        
        $data = array(
            'client_id' => $client_id,
            'name' => isset($_POST['contact_name']) ? sanitize_text_field(wp_unslash($_POST['contact_name'])) : '',
            'role' => isset($_POST['contact_role']) ? sanitize_text_field(wp_unslash($_POST['contact_role'])) : '',
            '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'])) : '',
            'is_primary' => isset($_POST['contact_is_primary']) ? 1 : 0,
            'is_billing_contact' => isset($_POST['contact_is_billing']) ? 1 : 0,
            'is_project_contact' => isset($_POST['contact_is_project']) ? 1 : 0,
        );
        
        if (empty($data['name'])) {
            wp_send_json_error(array('message' => __('Contact name is required', 'freelance-studio')));
        }
        
        if ($contact_id) {
            // Update existing contact
            $result = FS_Client_Contact::update($contact_id, $data);
            $message = __('Contact updated successfully', 'freelance-studio');
        } else {
            // Create new contact
            $result = FS_Client_Contact::create($data);
            $message = __('Contact added successfully', 'freelance-studio');
        }
        
        if ($result) {
            wp_send_json_success(array('message' => $message));
        } else {
            wp_send_json_error(array('message' => __('Failed to save contact', 'freelance-studio')));
        }
    }

    /**
     * Get contact data
     */
    public function get_contact() {
        $this->verify_nonce();
        
        $contact_id = isset($_POST['contact_id']) ? intval($_POST['contact_id']) : 0;
        
        if (!$contact_id) {
            wp_send_json_error(array('message' => __('Invalid contact ID', 'freelance-studio')));
        }
        
        $contact = FS_Client_Contact::get($contact_id);
        
        if (!$contact) {
            wp_send_json_error(array('message' => __('Contact not found', 'freelance-studio')));
        }
        
        wp_send_json_success($contact);
    }

    /**
     * Delete contact
     */
    public function delete_contact() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $contact_id = isset($_POST['contact_id']) ? intval($_POST['contact_id']) : 0;
        
        if (!$contact_id) {
            wp_send_json_error(array('message' => __('Invalid contact ID', 'freelance-studio')));
        }
        
        $result = FS_Client_Contact::delete($contact_id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Contact deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete contact', 'freelance-studio')));
        }
    }

    /**
     * Update invoice status inline
     */
    public function update_invoice_status() {
        $this->verify_nonce();
        $this->verify_capability('edit_posts');
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : '';
        
        if (!$invoice_id) {
            wp_send_json_error(array('message' => __('Invalid invoice ID', 'freelance-studio')));
        }
        
        $valid_statuses = array_keys(FS_Invoice::get_statuses());
        if (!in_array($status, $valid_statuses)) {
            wp_send_json_error(array('message' => __('Invalid status', 'freelance-studio')));
        }
        
        $result = FS_Invoice::update_status($invoice_id, $status);
        
        if ($result !== false) {
            wp_send_json_success(array(
                'message' => __('Status updated successfully', 'freelance-studio'),
                'status' => $status
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to update status', 'freelance-studio')));
        }
    }

    /**
     * Delete proposal
     */
    public function delete_proposal() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid proposal ID', 'freelance-studio')));
        }
        
        $proposal = FS_Proposal::get($id);
        
        if (!$proposal) {
            wp_send_json_error(array('message' => __('Proposal not found', 'freelance-studio')));
        }
        
        // Don't allow deleting approved/converted proposals that may be linked to contracts
        if ($proposal->status === 'converted') {
            wp_send_json_error(array('message' => __('Cannot delete a proposal that has been converted to a contract', 'freelance-studio')));
        }
        
        $result = FS_Proposal::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Proposal deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete proposal', 'freelance-studio')));
        }
    }

    /**
     * Delete contract
     */
    public function delete_contract() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid contract ID', 'freelance-studio')));
        }
        
        $contract = FS_Contract::get($id);
        
        if (!$contract) {
            wp_send_json_error(array('message' => __('Contract not found', 'freelance-studio')));
        }
        
        // Note: Allowing deletion of signed contracts for testing purposes
        // In production, you may want to prevent this or add additional confirmation
        
        $result = FS_Contract::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Contract deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete contract', 'freelance-studio')));
        }
    }

    /**
     * Delete form
     */
    public function delete_form() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid form ID', 'freelance-studio')));
        }
        
        $form = FS_Form::get($id);
        
        if (!$form) {
            wp_send_json_error(array('message' => __('Form not found', 'freelance-studio')));
        }
        
        $result = FS_Form::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Form deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete form', 'freelance-studio')));
        }
    }

    /**
     * Delete form response
     */
    public function delete_form_response() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid response ID', 'freelance-studio')));
        }
        
        $response = FS_Form_Response::get($id);
        
        if (!$response) {
            wp_send_json_error(array('message' => __('Response not found', 'freelance-studio')));
        }
        
        $result = FS_Form_Response::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Response deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete response', 'freelance-studio')));
        }
    }

    /**
     * Get client contacts for selection
     */
    public function get_client_contacts() {
        $this->verify_nonce();
        
        $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
        
        if (!$client_id) {
            wp_send_json_error(array('message' => __('Invalid client ID', 'freelance-studio')));
        }
        
        $client = FS_Client::get($client_id);
        
        if (!$client) {
            wp_send_json_error(array('message' => __('Client not found', 'freelance-studio')));
        }
        
        $contacts = FS_Client_Contact::get_by_client($client_id);
        
        // Format contacts for response
        $formatted_contacts = array();
        foreach ($contacts as $contact) {
            $formatted_contacts[] = array(
                'id' => $contact->id,
                'name' => $contact->name,
                'title' => isset($contact->role) ? $contact->role : '',
                'email' => $contact->email,
                'phone' => $contact->phone,
                'is_primary' => $contact->is_primary
            );
        }
        
        wp_send_json_success(array(
            'contacts' => $formatted_contacts,
            'client_name' => $client->company_name ?: $client->name,
            'primary_email' => $client->email
        ));
    }
    
    /**
     * Get client contacts for the send email modal
     */
    /**
     * Get the resolved email subject line for a document type
     */
    public function get_email_subject() {
        $this->verify_nonce();
        
        $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$type || !$id) {
            wp_send_json_error(array('message' => __('Missing parameters', 'freelance-studio')));
        }
        
        $settings = array(
            'business_name' => get_option('fs_business_name', ''),
        );
        
        $subject = '';
        
        if ($type === 'invoice') {
            $invoice = FS_Invoice::get($id);
            if (!$invoice) {
                wp_send_json_error(array('message' => __('Invoice not found', 'freelance-studio')));
            }
            $client = FS_Client::get($invoice->client_id);
            $project = $invoice->project_id ? FS_Project::get($invoice->project_id) : null;
            
            $template = FS_Email_Template::get('invoice_sent');
            $template_vars = array(
                '{client_name}' => $client ? ($client->company_name ?: $client->name) : '',
                '{invoice_number}' => $invoice->invoice_number,
                '{amount}' => FS_View_Context::format_currency($invoice->total),
                '{due_date}' => date_i18n(get_option('date_format'), strtotime($invoice->due_date)),
                '{project_name}' => $project ? $project->name : __('N/A', 'freelance-studio'),
                '{current_month}' => date_i18n('F'),
                '{business_name}' => $settings['business_name'],
            );
            $subject = FS_Email_Template::process_variables($template['subject'] ?? '', $template_vars);
            
        } elseif ($type === 'proposal') {
            $proposal = FS_Proposal::get($id);
            if (!$proposal) {
                wp_send_json_error(array('message' => __('Proposal not found', 'freelance-studio')));
            }
            $client = FS_Client::get($proposal->client_id);
            
            $template = FS_Email_Template::get('proposal_sent');
            $template_vars = array(
                '{client_name}' => $client ? ($client->company_name ?: $client->name) : '',
                '{proposal_title}' => $proposal->title,
                '{amount}' => FS_View_Context::format_currency($proposal->total_amount),
                '{valid_until}' => !empty($proposal->valid_until) ? date_i18n(get_option('date_format'), strtotime($proposal->valid_until)) : '',
                '{business_name}' => $settings['business_name'],
            );
            $subject = FS_Email_Template::process_variables($template['subject'] ?? '', $template_vars);
            
            // Prepend "Revised" for proposals that were revised or had changes requested
            if (in_array($proposal->status, array('revised', 'revision_requested'))) {
                $subject = __('Revised', 'freelance-studio') . ' ' . $subject;
            }
            
        } elseif ($type === 'contract') {
            $contract = FS_Contract::get($id);
            if (!$contract) {
                wp_send_json_error(array('message' => __('Contract not found', 'freelance-studio')));
            }
            $client = FS_Client::get($contract->client_id);
            
            $template = FS_Email_Template::get('contract_sent');
            $template_vars = array(
                '{client_name}' => $client ? ($client->company_name ?: $client->name) : '',
                '{contract_title}' => $contract->title,
                '{business_name}' => $settings['business_name'],
            );
            $subject = FS_Email_Template::process_variables($template['subject'] ?? '', $template_vars);
            
            // Prepend "Updated" for contracts being resent after viewing
            if (in_array($contract->status, array('viewed'))) {
                $subject = __('Updated', 'freelance-studio') . ' ' . $subject;
            }
        } elseif ($type === 'payment_receipt') {
            $invoice = FS_Invoice::get($id);
            if (!$invoice) {
                wp_send_json_error(array('message' => __('Invoice not found', 'freelance-studio')));
            }
            $client = FS_Client::get($invoice->client_id);
            $project = $invoice->project_id ? FS_Project::get($invoice->project_id) : null;
            
            $template = FS_Email_Template::get('payment_received');
            $template_vars = array(
                '{client_name}' => $client ? ($client->company_name ?: $client->name) : '',
                '{invoice_number}' => $invoice->invoice_number,
                '{amount}' => FS_View_Context::format_currency($invoice->total),
                '{payment_date}' => date_i18n(get_option('date_format')),
                '{project_name}' => $project ? $project->name : __('N/A', 'freelance-studio'),
                '{business_name}' => $settings['business_name'],
            );
            $subject = FS_Email_Template::process_variables($template['subject'] ?? '', $template_vars);
        }
        
        wp_send_json_success(array('subject' => $subject));
    }

    public function get_client_contacts_for_send() {
        $this->verify_nonce();
        
        $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
        
        if (!$client_id) {
            wp_send_json_error(array('message' => __('Invalid client ID', 'freelance-studio')));
        }
        
        $client = FS_Client::get($client_id);
        
        if (!$client) {
            wp_send_json_error(array('message' => __('Client not found', 'freelance-studio')));
        }
        
        $contacts = FS_Client_Contact::get_by_client($client_id);
        
        // Format contacts for response
        $formatted_contacts = array();
        foreach ($contacts as $contact) {
            if (!empty($contact->email)) {
                $formatted_contacts[] = array(
                    'id' => $contact->id,
                    'name' => $contact->name,
                    'role' => isset($contact->role) ? $contact->role : '',
                    'email' => $contact->email,
                );
            }
        }
        
        wp_send_json_success(array(
            'contacts' => $formatted_contacts,
            'client_name' => $client->company_name ?: $client->name,
            'primary_email' => $client->email
        ));
    }

    /**
     * Send invoice with custom message via AJAX
     */
    public function send_invoice_with_message() {
        $this->verify_nonce();
        
        // Check free plan invoice send limit
        if ( function_exists( 'fs_studio_limit_reached' ) && fs_studio_limit_reached( 'invoice_sends' ) ) {
            $limits = fs_studio_free_limits();
            wp_send_json_error(array(
                /* translators: placeholder(s) in this string */
                'message' => sprintf( __( 'You\'ve used all %d invoice sends this month on the free plan. Upgrade to Pro for unlimited sends.', 'freelance-studio' ), $limits['invoice_sends'] ),
                'upgrade_url' => function_exists( 'freelance_studio_fs' ) ? freelance_studio_fs()->get_upgrade_url() : '',
            ));
        }
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        $custom_message = isset($_POST['custom_message']) ? sanitize_textarea_field(wp_unslash($_POST['custom_message'])) : '';
        $custom_subject = isset($_POST['custom_subject']) ? sanitize_text_field(wp_unslash($_POST['custom_subject'])) : '';
        
        if (!$invoice_id) {
            wp_send_json_error(array('message' => __('Invalid invoice ID', 'freelance-studio')));
        }
        
        $invoice = FS_Invoice::get($invoice_id);
        
        if (!$invoice) {
            wp_send_json_error(array('message' => __('Invoice not found', 'freelance-studio')));
        }
        
        // Get primary recipient and CC contacts from new To/CC UI
        $primary_recipient = isset($_POST['primary_recipient']) ? sanitize_text_field($_POST['primary_recipient']) : 'primary';
        $cc_contact_ids = array();
        if (isset($_POST['cc_contacts']) && is_array($_POST['cc_contacts'])) {
            foreach ($_POST['cc_contacts'] as $cc) {
                if ($cc === 'primary') {
                    $cc_contact_ids[] = 'primary';
                } else {
                    $cc_contact_ids[] = intval($cc);
                }
            }
        }
        
        // Send the invoice with custom message and subject
        $sent = FS_Email_Service::send_invoice($invoice_id, array(), $custom_message, $custom_subject, true, $primary_recipient, $cc_contact_ids);
        
        // Save the selected recipients on the invoice for future reminders
        if ($sent) {
            global $wpdb;
            $recipients_data = json_encode(array(
                'to' => $primary_recipient,
                'cc' => $cc_contact_ids,
            ));
            $wpdb->update(
                $wpdb->prefix . 'fs_invoices',
                array('send_to_contacts' => $recipients_data),
                array('id' => $invoice_id)
            );
        }
        
        // Increment the monthly send counter for free plan tracking
        if ( function_exists( 'fs_studio_increment_invoice_sends' ) ) {
            fs_studio_increment_invoice_sends();
        }
        
        if ($sent) {
            wp_send_json_success(array(
                'message' => __('Invoice sent successfully', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=view&id=' . $invoice_id . '&message=sent')
            ));
        } else {
            wp_send_json_success(array(
                'message' => __('Invoice marked as sent, but email delivery may have failed', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=view&id=' . $invoice_id . '&message=sent_no_email')
            ));
        }
    }

    /**
     * Send payment receipt email via AJAX
     * v7.9.6
     */
    public function send_payment_receipt() {
        $this->verify_nonce();
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        $custom_message = isset($_POST['custom_message']) ? sanitize_textarea_field(wp_unslash($_POST['custom_message'])) : '';
        $custom_subject = isset($_POST['custom_subject']) ? sanitize_text_field(wp_unslash($_POST['custom_subject'])) : '';
        
        if (!$invoice_id) {
            wp_send_json_error(array('message' => __('Invalid invoice ID', 'freelance-studio')));
        }
        
        $invoice = FS_Invoice::get($invoice_id);
        
        if (!$invoice) {
            wp_send_json_error(array('message' => __('Invoice not found', 'freelance-studio')));
        }
        
        // Get primary recipient and CC contacts
        $primary_recipient = isset($_POST['primary_recipient']) ? sanitize_text_field($_POST['primary_recipient']) : 'primary';
        $cc_contact_ids = array();
        if (isset($_POST['cc_contacts']) && is_array($_POST['cc_contacts'])) {
            foreach ($_POST['cc_contacts'] as $cc) {
                if ($cc === 'primary') {
                    $cc_contact_ids[] = 'primary';
                } else {
                    $cc_contact_ids[] = intval($cc);
                }
            }
        }
        
        // Send the payment receipt
        $sent = FS_Email_Service::send_payment_receipt($invoice_id, floatval($invoice->amount_paid), $custom_message, $custom_subject, $primary_recipient, $cc_contact_ids);
        
        if ($sent) {
            wp_send_json_success(array(
                'message' => __('Payment receipt sent successfully', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=view&id=' . $invoice_id . '&message=receipt_sent')
            ));
        } else {
            wp_send_json_error(array(
                'message' => __('Failed to send payment receipt. Please check your email settings.', 'freelance-studio'),
            ));
        }
    }

    /**
     * Send proposal with custom message via AJAX
     */
    public function send_proposal_with_message() {
        $this->verify_nonce();
        
        $proposal_id = isset($_POST['proposal_id']) ? intval($_POST['proposal_id']) : 0;
        $custom_message = isset($_POST['custom_message']) ? sanitize_textarea_field(wp_unslash($_POST['custom_message'])) : '';
        $custom_subject = isset($_POST['custom_subject']) ? sanitize_text_field(wp_unslash($_POST['custom_subject'])) : '';
        
        if (!$proposal_id) {
            wp_send_json_error(array('message' => __('Invalid proposal ID', 'freelance-studio')));
        }
        
        $proposal = FS_Proposal::get($proposal_id);
        
        if (!$proposal) {
            wp_send_json_error(array('message' => __('Proposal not found', 'freelance-studio')));
        }
        
        // Get primary recipient and CC contacts from new To/CC UI
        $primary_recipient = isset($_POST['primary_recipient']) ? sanitize_text_field($_POST['primary_recipient']) : 'primary';
        $cc_contact_ids = array();
        if (isset($_POST['cc_contacts']) && is_array($_POST['cc_contacts'])) {
            foreach ($_POST['cc_contacts'] as $cc) {
                if ($cc === 'primary') {
                    $cc_contact_ids[] = 'primary';
                } else {
                    $cc_contact_ids[] = intval($cc);
                }
            }
        }
        
        // Send the proposal with custom message and subject
        $sent = FS_Email_Service::send_proposal($proposal_id, array(), $custom_message, $custom_subject, true, $primary_recipient, $cc_contact_ids);
        
        if ($sent) {
            wp_send_json_success(array(
                'message' => __('Proposal sent successfully', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-proposals&action=view&id=' . $proposal_id . '&message=sent')
            ));
        } else {
            // Still mark as sent even if email fails
            FS_Proposal::update_status($proposal_id, FS_Proposal::STATUS_SENT);
            wp_send_json_success(array(
                'message' => __('Proposal marked as sent, but email delivery may have failed', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-proposals&action=view&id=' . $proposal_id . '&message=sent_no_email')
            ));
        }
    }

    /**
     * Send contract with optional custom message
     */
    public function send_contract_with_message() {
        $this->verify_nonce();
        
        $contract_id = isset($_POST['contract_id']) ? intval($_POST['contract_id']) : 0;
        $custom_message = isset($_POST['custom_message']) ? sanitize_textarea_field(wp_unslash($_POST['custom_message'])) : '';
        $custom_subject = isset($_POST['custom_subject']) ? sanitize_text_field(wp_unslash($_POST['custom_subject'])) : '';
        
        if (!$contract_id) {
            wp_send_json_error(array('message' => __('Invalid contract ID', 'freelance-studio')));
        }
        
        $contract = FS_Contract::get($contract_id);
        
        if (!$contract) {
            wp_send_json_error(array('message' => __('Contract not found', 'freelance-studio')));
        }
        
        // Get primary recipient and CC contacts from new To/CC UI
        $primary_recipient = isset($_POST['primary_recipient']) ? sanitize_text_field($_POST['primary_recipient']) : 'primary';
        $cc_contact_ids = array();
        if (isset($_POST['cc_contacts']) && is_array($_POST['cc_contacts'])) {
            foreach ($_POST['cc_contacts'] as $cc) {
                if ($cc === 'primary') {
                    $cc_contact_ids[] = 'primary';
                } else {
                    $cc_contact_ids[] = intval($cc);
                }
            }
        }
        
        // Send the contract with custom message and subject
        $sent = FS_Email_Service::send_contract($contract_id, array(), $custom_message, $custom_subject, true, $primary_recipient, $cc_contact_ids);
        
        if ($sent) {
            wp_send_json_success(array(
                'message' => __('Contract sent successfully', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-contracts&action=view&id=' . $contract_id . '&message=sent')
            ));
        } else {
            // Still mark as sent even if email fails
            FS_Contract::update_status($contract_id, FS_Contract::STATUS_SENT);
            wp_send_json_success(array(
                'message' => __('Contract marked as sent, but email delivery may have failed', 'freelance-studio'),
                'redirect_url' => admin_url('admin.php?page=fs-contracts&action=view&id=' . $contract_id . '&message=sent_no_email')
            ));
        }
    }

    /**
     * Sign contract (public - client side)
     */
    public function sign_contract() {
        // Verify nonce
        if (!check_ajax_referer('fs_sign_contract', 'fs_sign_nonce', false)) {
            wp_send_json_error(array('message' => __('Security check failed. Please refresh the page and try again.', 'freelance-studio')));
        }
        
        // E-signatures are a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( array('message' => __( 'Electronic signatures are available with Freelance Studio Pro.', 'freelance-studio' )) );
        }
        
        $contract_id = isset($_POST['contract_id']) ? intval($_POST['contract_id']) : 0;
        $token = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : '';
        $signature = isset($_POST['signature_data']) ? $_POST['signature_data'] : (isset($_POST['signature']) ? $_POST['signature'] : '');
        $name = isset($_POST['signer_name']) ? sanitize_text_field(wp_unslash($_POST['signer_name'])) : (isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '');
        
        if (!$contract_id || empty($signature) || empty($name)) {
            wp_send_json_error(array('message' => __('Missing required fields', 'freelance-studio')));
        }
        
        // Verify contract
        $contract = FS_Contract::get($contract_id);
        
        if (!$contract) {
            wp_send_json_error(array('message' => __('Invalid contract', 'freelance-studio')));
        }
        
        // If token was provided, verify it matches
        if (!empty($token) && $contract->view_token !== $token) {
            wp_send_json_error(array('message' => __('Invalid contract', 'freelance-studio')));
        }
        
        // Check if already signed
        if ($contract->status === 'signed' || $contract->status === 'completed') {
            wp_send_json_error(array('message' => __('This contract has already been signed', 'freelance-studio')));
        }
        
        // Check if expired
        if (FS_Contract::is_expired($contract)) {
            wp_send_json_error(array('message' => __('This contract has expired', 'freelance-studio')));
        }
        
        // Validate signature data (should be base64 image)
        if (strpos($signature, 'data:image') !== 0) {
            wp_send_json_error(array('message' => __('Invalid signature format', 'freelance-studio')));
        }
        
        // Sign the contract
        $result = FS_Contract::sign($contract_id, array(
            'signature' => $signature,
            'name' => $name,
        ));
        
        if ($result) {
            wp_send_json_success(array(
                'message' => __('Contract signed successfully', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to sign contract', 'freelance-studio')));
        }
    }

    /**
     * Start timer
     */
    public function start_timer() {
        $this->verify_nonce();
        
        // Live timer is a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( __( 'The live timer is available with Freelance Studio Pro. Manual time entry is available on the free plan.', 'freelance-studio' ) );
        }
        
        $data = array(
            'project_id' => isset($_POST['project_id']) ? intval($_POST['project_id']) : 0,
            'task_id' => isset($_POST['task_id']) ? intval($_POST['task_id']) : 0,
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'activity' => isset($_POST['activity']) ? sanitize_text_field(wp_unslash($_POST['activity'])) : 'development',
        );
        
        // Check if the task is marked as non-billable and set billable accordingly
        if (!empty($data['task_id'])) {
            $task = FS_Task::get($data['task_id']);
            if ($task && !empty($task->is_non_billable)) {
                $data['billable'] = 0;
            }
        }
        
        $entry_id = FS_Time_Entry::start_timer($data);
        
        if ($entry_id) {
            wp_send_json_success(array(
                'entry_id' => $entry_id,
                'message' => __('Timer started', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to start timer', 'freelance-studio'));
        }
    }

    /**
     * Stop timer
     */
    public function stop_timer() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        // Update entry with current selections before stopping
        $update_data = array();
        
        if (isset($_POST['project_id'])) {
            $update_data['project_id'] = intval($_POST['project_id']);
        }
        if (isset($_POST['task_id'])) {
            $update_data['task_id'] = intval($_POST['task_id']);
        }
        if (isset($_POST['activity'])) {
            $update_data['activity'] = sanitize_text_field(wp_unslash($_POST['activity']));
        }
        if (isset($_POST['description'])) {
            $update_data['description'] = sanitize_textarea_field(wp_unslash($_POST['description']));
        }
        
        // Update the entry with current field values if any were provided
        if (!empty($update_data)) {
            FS_Time_Entry::update($entry_id, $update_data);
        }
        
        $result = FS_Time_Entry::stop_timer($entry_id);
        
        if ($result) {
            $entry = FS_Time_Entry::get($entry_id);
            
            // v7.9.0: Check hour cap and send notification if cap was just reached
            if ($entry && $entry->project_id) {
                $this->check_hour_cap_notification($entry->project_id);
            }
            
            wp_send_json_success(array(
                'duration' => $entry->duration,
                'formatted_duration' => FS_Time_Entry::format_duration($entry->duration),
                'message' => __('Timer stopped', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to stop timer', 'freelance-studio'));
        }
    }

    /**
     * Pause timer
     */
    public function pause_timer() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        $result = FS_Time_Entry::pause_timer($entry_id);
        
        if ($result) {
            $entry = FS_Time_Entry::get($entry_id);
            wp_send_json_success(array(
                'paused_duration' => $entry->paused_duration,
                'formatted_duration' => FS_Time_Entry::format_duration($entry->paused_duration),
                'message' => __('Timer paused', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to pause timer', 'freelance-studio'));
        }
    }

    /**
     * Resume timer
     */
    public function resume_timer() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        $result = FS_Time_Entry::resume_timer($entry_id);
        
        if ($result) {
            wp_send_json_success(array(
                'message' => __('Timer resumed', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to resume timer', 'freelance-studio'));
        }
    }
    
    /**
     * Bulk time entry action
     */
    public function bulk_time_action() {
        $this->verify_nonce();
        
        $action = isset($_POST['bulk_action']) ? sanitize_text_field(wp_unslash($_POST['bulk_action'])) : '';
        $entry_ids = isset($_POST['entry_ids']) ? array_map('intval', $_POST['entry_ids']) : array();
        
        if (empty($action) || empty($entry_ids)) {
            wp_send_json_error(__('Invalid request', 'freelance-studio'));
        }
        
        $success_count = 0;
        
        foreach ($entry_ids as $entry_id) {
            switch ($action) {
                case 'mark_billable':
                    if (FS_Time_Entry::update($entry_id, array('billable' => 1))) {
                        $success_count++;
                    }
                    break;
                    
                case 'mark_non_billable':
                    if (FS_Time_Entry::update($entry_id, array('billable' => 0))) {
                        $success_count++;
                    }
                    break;
                    
                case 'mark_invoiced':
                    if (FS_Time_Entry::update($entry_id, array('invoiced' => 1))) {
                        $success_count++;
                    }
                    break;
                    
                case 'delete':
                    if (FS_Time_Entry::delete($entry_id)) {
                        $success_count++;
                    }
                    break;
            }
        }
        
        wp_send_json_success(array(
            /* translators: 1. count/number */
            'message' => sprintf(__('%d entries updated', 'freelance-studio'), $success_count),
            'count' => $success_count
        ));
    }
    
    /**
     * Save dashboard widget order
     */
    public function save_widget_order() {
        $this->verify_nonce();
        
        $order = isset($_POST['order']) ? array_map('sanitize_text_field', $_POST['order']) : array();
        
        if (!empty($order)) {
            update_user_meta(get_current_user_id(), 'fs_dashboard_widget_order', $order);
            wp_send_json_success();
        } else {
            wp_send_json_error(__('Invalid order', 'freelance-studio'));
        }
    }
    
    /**
     * Reset dashboard widget order to default
     */
    public function reset_widget_order() {
        $this->verify_nonce();
        
        delete_user_meta(get_current_user_id(), 'fs_dashboard_widget_order');
        wp_send_json_success();
    }
    
    /**
     * Save dashboard widget layout (two-column with column spans)
     */
    public function save_widget_layout() {
        $this->verify_nonce();
        
        $layout_json = isset($_POST['layout']) ? stripslashes($_POST['layout']) : '';
        $layout = json_decode($layout_json, true);
        
        if ($layout && isset($layout['main']) && isset($layout['sidebar'])) {
            // Sanitize the layout data
            $clean_layout = array('main' => array(), 'sidebar' => array());
            
            foreach ($layout['main'] as $widget) {
                if (isset($widget['id'])) {
                    $clean_layout['main'][] = array(
                        'id' => sanitize_text_field($widget['id']),
                        'cols' => isset($widget['cols']) ? intval($widget['cols']) : 2
                    );
                }
            }
            
            foreach ($layout['sidebar'] as $widget) {
                if (isset($widget['id'])) {
                    $clean_layout['sidebar'][] = array(
                        'id' => sanitize_text_field($widget['id']),
                        'cols' => 1
                    );
                }
            }
            
            update_user_meta(get_current_user_id(), 'fs_dashboard_layout', $clean_layout);
            wp_send_json_success();
        } else {
            wp_send_json_error(__('Invalid layout data', 'freelance-studio'));
        }
    }
    
    /**
     * Reset dashboard widget layout to default
     */
    public function reset_widget_layout() {
        $this->verify_nonce();
        
        delete_user_meta(get_current_user_id(), 'fs_dashboard_layout');
        wp_send_json_success();
    }
    
    /**
     * Save task widget filter preferences
     */
    public function save_task_widget_filter() {
        $this->verify_nonce();
        
        $statuses = isset($_POST['statuses']) ? array_map('sanitize_text_field', $_POST['statuses']) : array();
        
        // Validate statuses
        $valid_statuses = array('pending', 'in_progress', 'needs_info', 'with_client');
        $statuses = array_intersect($statuses, $valid_statuses);
        
        if (empty($statuses)) {
            $statuses = array('pending', 'in_progress'); // Default
        }
        
        update_option('fs_dashboard_task_statuses', $statuses);
        wp_send_json_success();
    }

    /**
     * Save dashboard task display limit
     */
    public function save_task_widget_limit() {
        $this->verify_nonce();
        
        $limit = isset($_POST['limit']) ? sanitize_text_field($_POST['limit']) : '5';
        
        if ($limit === 'all') {
            update_option('fs_dashboard_task_limit', 'all');
        } else {
            $limit = intval($limit);
            if (!in_array($limit, array(5, 10, 20))) {
                $limit = 5;
            }
            update_option('fs_dashboard_task_limit', $limit);
        }
        
        wp_send_json_success();
    }

    /**
     * Save task sort preference (persistent across sessions)
     */
    public function save_task_sort_preference() {
        $this->verify_nonce();
        
        $orderby = isset($_POST['orderby']) ? sanitize_text_field($_POST['orderby']) : 'due_date';
        $order = isset($_POST['order']) ? sanitize_text_field($_POST['order']) : 'ASC';
        
        // Validate orderby
        $allowed_orderby = array('due_date', 'title', 'priority', 'status', 'client_name', 'position');
        if (!in_array($orderby, $allowed_orderby)) {
            $orderby = 'due_date';
        }
        
        // Validate order
        $order = strtoupper($order) === 'DESC' ? 'DESC' : 'ASC';
        
        update_user_meta(get_current_user_id(), 'fs_task_sort_orderby', $orderby);
        update_user_meta(get_current_user_id(), 'fs_task_sort_order', $order);
        
        wp_send_json_success();
    }

    /**
     * Toggle external calendar events visibility on dashboard week widget
     */
    public function toggle_week_external_events() {
        $this->verify_nonce();
        
        $show = isset($_POST['show']) ? intval($_POST['show']) : 1;
        update_user_meta(get_current_user_id(), 'fs_dashboard_show_external_events', $show);
        wp_send_json_success();
    }

    /**
     * Save time entry
     */
    /**
     * Get a single time entry
     */
    public function get_time_entry() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        if (!$id) {
            wp_send_json_error(__('Invalid time entry ID', 'freelance-studio'));
        }
        
        $entry = FS_Time_Entry::get($id);
        if (!$entry) {
            wp_send_json_error(__('Time entry not found', 'freelance-studio'));
        }
        
        wp_send_json_success($entry);
    }

    /**
     * Save time entry
     */
    public function save_time_entry() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        // Build start datetime (user provides local time)
        $start_date = isset($_POST['start_date']) ? sanitize_text_field(wp_unslash($_POST['start_date'])) : gmdate('Y-m-d');
        $start_time = isset($_POST['start_time']) && !empty($_POST['start_time']) ? sanitize_text_field(wp_unslash($_POST['start_time'])) : '09:00';
        // Normalize to H:i:s — only append :00 if seconds are not already present
        if (substr_count($start_time, ':') < 2) {
            $start_time .= ':00';
        }
        $start_datetime_local = $start_date . ' ' . $start_time;
        
        // Convert local time to UTC for storage
        $start_datetime = FS_Time_Entry::local_to_utc($start_datetime_local);
        
        // Calculate duration
        $duration = isset($_POST['duration']) ? intval($_POST['duration']) : 0;
        
        // Build end datetime
        $end_datetime = null;
        if (!empty($_POST['end_time'])) {
            // User provided explicit end time (local)
            $end_time = sanitize_text_field(wp_unslash($_POST['end_time']));
            // Normalize to H:i:s
            if (substr_count($end_time, ':') < 2) {
                $end_time .= ':00';
            }
            $end_datetime_local = $start_date . ' ' . $end_time;
            
            // Convert local to UTC
            $end_datetime = FS_Time_Entry::local_to_utc($end_datetime_local);
            
            // Calculate duration from time range if not already set
            if (!$duration) {
                $duration = strtotime($end_datetime) - strtotime($start_datetime);
                if ($duration < 0) {
                    // Handle overnight
                    $duration += 86400;
                }
            }
        } elseif ($duration > 0) {
            // No end time provided but duration exists - calculate end time from UTC start
            $end_datetime = gmdate('Y-m-d H:i:s', strtotime($start_datetime) + $duration);
        }
        
        $data = array(
            'project_id' => isset($_POST['project_id']) ? intval($_POST['project_id']) : 0,
            'task_id' => isset($_POST['task_id']) && !empty($_POST['task_id']) ? intval($_POST['task_id']) : null,
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'activity' => isset($_POST['activity']) ? sanitize_text_field(wp_unslash($_POST['activity'])) : 'other',
            'start_time' => $start_datetime,
            'end_time' => $end_datetime,
            'duration' => $duration,
            'hourly_rate' => isset($_POST['hourly_rate']) ? floatval($_POST['hourly_rate']) : null,
            'billable' => !empty($_POST['billable']) ? 1 : 0,
        );
        
        // If a task is linked and marked non-billable, override to non-billable
        if (!empty($data['task_id'])) {
            $linked_task = FS_Task::get($data['task_id']);
            if ($linked_task && !empty($linked_task->is_non_billable)) {
                $data['billable'] = 0;
            }
        }
        
        if ($entry_id) {
            $result = FS_Time_Entry::update($entry_id, $data);
            $message = __('Time entry updated', 'freelance-studio');
        } else {
            $result = FS_Time_Entry::create($data);
            $entry_id = $result;
            $message = __('Time entry created', 'freelance-studio');
        }
        
        if ($result) {
            wp_send_json_success(array(
                'entry_id' => $entry_id,
                'message' => $message,
            ));
        } else {
            wp_send_json_error(__('Failed to save time entry', 'freelance-studio'));
        }
    }

    /**
     * Delete time entry
     */
    public function delete_time_entry() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        $result = FS_Time_Entry::delete($entry_id);
        
        if ($result) {
            wp_send_json_success(array(
                'message' => __('Time entry deleted', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to delete time entry', 'freelance-studio'));
        }
    }

    /**
     * Quick add time entry (from week view)
     */
    public function quick_add_time() {
        $this->verify_nonce();
        
        $date = isset($_POST['date']) ? sanitize_text_field(wp_unslash($_POST['date'])) : current_time('Y-m-d');
        $duration = isset($_POST['duration']) ? intval($_POST['duration']) : 0;
        
        if (!$duration) {
            wp_send_json_error(__('Please enter a duration', 'freelance-studio'));
        }
        
        // Create entry with duration only (no specific start/end times - set to 9am local)
        $start_time_local = $date . ' 09:00:00';
        
        // Convert local time to UTC for storage
        $start_time = FS_Time_Entry::local_to_utc($start_time_local);
        $end_time = gmdate('Y-m-d H:i:s', strtotime($start_time) + $duration);
        
        $data = array(
            'project_id' => isset($_POST['project_id']) ? intval($_POST['project_id']) : 0,
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'activity' => isset($_POST['activity']) ? sanitize_text_field(wp_unslash($_POST['activity'])) : 'development',
            'start_time' => $start_time,
            'end_time' => $end_time,
            'duration' => $duration,
            'billable' => !empty($_POST['billable']) ? 1 : 0,
        );
        
        $entry_id = FS_Time_Entry::create($data);
        
        if ($entry_id) {
            wp_send_json_success(array(
                'entry_id' => $entry_id,
                'message' => __('Time entry added', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to add time entry', 'freelance-studio'));
        }
    }

    /**
     * Generate invoice from time entries
     */
    public function generate_invoice_from_time() {
        $this->verify_nonce();
        
        // Time-to-invoice linking is a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( __( 'Invoicing time entries is available with Freelance Studio Pro.', 'freelance-studio' ) );
        }
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(__('Invalid project ID', 'freelance-studio'));
        }
        
        // Get specific entry IDs if provided
        $entry_ids = array();
        if (!empty($_POST['entry_ids'])) {
            $entry_ids = array_map('intval', (array) $_POST['entry_ids']);
        }
        
        $options = array();
        if (!empty($_POST['notes'])) {
            $options['notes'] = sanitize_textarea_field(wp_unslash($_POST['notes']));
        }
        if (!empty($_POST['due_date'])) {
            $options['due_date'] = sanitize_text_field(wp_unslash($_POST['due_date']));
        }
        
        $invoice_id = FS_Invoice::create_from_time_entries($project_id, $entry_ids, $options);
        
        if ($invoice_id) {
            wp_send_json_success(array(
                'invoice_id' => $invoice_id,
                'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=edit&id=' . $invoice_id),
                'message' => __('Invoice created successfully', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to create invoice. No unbilled time entries found.', 'freelance-studio'));
        }
    }

    /**
     * Get unbilled time for a project
     */
    public function get_unbilled_time() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(__('Invalid project ID', 'freelance-studio'));
        }
        
        $entries = FS_Time_Entry::get_unbilled($project_id);
        $summary = FS_Time_Entry::get_summary(array(
            'project_id' => $project_id,
        ));
        
        $formatted_entries = array();
        $activity_types = FS_Time_Entry::get_activity_types();
        
        foreach ($entries as $entry) {
            // Convert UTC start_time to local date for display
            $local_date = FS_Time_Entry::get_local_date($entry->start_time);
            $formatted_entries[] = array(
                'id' => $entry->id,
                'date' => gmdate('M j, Y', strtotime($local_date)),
                'duration' => FS_Time_Entry::format_duration($entry->duration),
                'hours' => FS_Time_Entry::get_rounded_hours($entry->duration),
                'activity' => $activity_types[$entry->activity] ?? $entry->activity,
                'description' => $entry->description,
                'rate' => $entry->hourly_rate,
                'amount' => round(FS_Time_Entry::get_rounded_hours($entry->duration) * $entry->hourly_rate, 2),
            );
        }
        
        wp_send_json_success(array(
            'entries' => $formatted_entries,
            'total_hours' => round(($summary->uninvoiced_billable_seconds ?: 0) / 3600, 2),
            'total_amount' => round($summary->uninvoiced_amount ?: 0, 2),
        ));
    }

    /**
     * Duplicate a time entry
     */
    public function duplicate_time_entry() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        $entry = FS_Time_Entry::get($entry_id);
        if (!$entry) {
            wp_send_json_error(__('Entry not found', 'freelance-studio'));
        }
        
        // Create duplicate with today's date
        $new_entry_id = FS_Time_Entry::create(array(
            'project_id' => $entry->project_id,
            'task_id' => $entry->task_id,
            'description' => $entry->description,
            'activity' => $entry->activity,
            'start_time' => current_time('mysql'),
            'end_time' => gmdate('Y-m-d H:i:s', strtotime(current_time('mysql')) + $entry->duration),
            'duration' => $entry->duration,
            'billable' => $entry->billable,
            'hourly_rate' => $entry->hourly_rate,
        ));
        
        if ($new_entry_id) {
            wp_send_json_success(array(
                'entry_id' => $new_entry_id,
                'message' => __('Entry duplicated', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to duplicate entry', 'freelance-studio'));
        }
    }

    /**
     * Toggle billable status of a time entry
     */
    public function toggle_time_billable() {
        $this->verify_nonce();
        
        $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
        $billable = isset($_POST['billable']) ? intval($_POST['billable']) : 0;
        
        if (!$entry_id) {
            wp_send_json_error(__('Invalid entry ID', 'freelance-studio'));
        }
        
        $result = FS_Time_Entry::update($entry_id, array('billable' => $billable));
        
        if ($result) {
            wp_send_json_success(array(
                'message' => $billable ? __('Marked as billable', 'freelance-studio') : __('Marked as non-billable', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to update entry', 'freelance-studio'));
        }
    }

    /**
     * Delete recurring invoice
     */
    public function delete_recurring_invoice() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid recurring invoice ID', 'freelance-studio'));
        }
        
        $result = FS_Recurring_Invoice::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Recurring invoice deleted', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to delete recurring invoice', 'freelance-studio'));
        }
    }

    /**
     * Update recurring invoice status
     */
    public function update_recurring_status() {
        $this->verify_nonce();
        $this->verify_capability('edit_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : '';
        
        if (!$id || !$status) {
            wp_send_json_error(__('Invalid parameters', 'freelance-studio'));
        }
        
        $valid_statuses = array('active', 'paused', 'completed', 'cancelled');
        if (!in_array($status, $valid_statuses)) {
            wp_send_json_error(__('Invalid status', 'freelance-studio'));
        }
        
        $result = FS_Recurring_Invoice::update($id, array('status' => $status));
        
        if ($result !== false) {
            wp_send_json_success(array('message' => __('Status updated', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to update status', 'freelance-studio'));
        }
    }

    /**
     * Delete project template
     */
    public function delete_project_template() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid template ID', 'freelance-studio'));
        }
        
        $result = FS_Project_Template::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Template deleted', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to delete template', 'freelance-studio'));
        }
    }

    /**
     * Get milestone data
     */
    public function get_milestone() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid milestone ID', 'freelance-studio')));
        }
        
        $milestone = FS_Milestone::get($id);
        
        if (!$milestone) {
            wp_send_json_error(array('message' => __('Milestone not found', 'freelance-studio')));
        }
        
        wp_send_json_success($milestone);
    }

    /**
     * Save milestone
     */
    public function save_milestone() {
        $this->verify_nonce();
        
        // Milestones are a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( __( 'Project milestones are available with Freelance Studio Pro.', 'freelance-studio' ) );
        }
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        $data = array(
            'project_id' => isset($_POST['project_id']) ? intval($_POST['project_id']) : 0,
            'name' => isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '',
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'due_date' => isset($_POST['due_date']) ? sanitize_text_field(wp_unslash($_POST['due_date'])) : null,
            'invoice_on_completion' => isset($_POST['invoice_on_completion']) ? intval($_POST['invoice_on_completion']) : 0,
            'invoice_percentage' => isset($_POST['invoice_percentage']) ? floatval($_POST['invoice_percentage']) : null,
        );
        
        if (empty($data['name'])) {
            wp_send_json_error(__('Milestone name is required', 'freelance-studio'));
        }
        
        if ($id) {
            $result = FS_Milestone::update($id, $data);
            $message = __('Milestone updated', 'freelance-studio');
        } else {
            $result = FS_Milestone::create($data);
            $id = $result;
            $message = __('Milestone created', 'freelance-studio');
        }
        
        if ($result) {
            $milestone = FS_Milestone::get($id);
            wp_send_json_success(array('message' => $message, 'milestone' => $milestone));
        } else {
            wp_send_json_error(__('Failed to save milestone', 'freelance-studio'));
        }
    }

    /**
     * Delete milestone
     */
    public function delete_milestone() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid milestone ID', 'freelance-studio'));
        }
        
        $result = FS_Milestone::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Milestone deleted', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to delete milestone', 'freelance-studio'));
        }
    }

    /**
     * Complete milestone
     */
    public function complete_milestone() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid milestone ID', 'freelance-studio'));
        }
        
        $result = FS_Milestone::complete($id);
        $milestone = FS_Milestone::get($id);
        
        if ($result) {
            $message = __('Milestone completed', 'freelance-studio');
            if (is_numeric($result) && $result > 0) {
                $message .= ' ' . __('Invoice generated.', 'freelance-studio');
            }
            wp_send_json_success(array('message' => $message, 'milestone' => $milestone, 'invoice_id' => is_numeric($result) ? $result : null));
        } else {
            wp_send_json_error(__('Failed to complete milestone', 'freelance-studio'));
        }
    }

    /**
     * Save task
     */
    public function save_task() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        $data = array(
            'project_id' => isset($_POST['project_id']) && $_POST['project_id'] !== '' ? intval($_POST['project_id']) : null,
            'client_id' => isset($_POST['client_id']) && $_POST['client_id'] !== '' ? intval($_POST['client_id']) : null,
            'milestone_id' => isset($_POST['milestone_id']) && $_POST['milestone_id'] !== '' ? intval($_POST['milestone_id']) : null,
            'title' => isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '',
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'priority' => isset($_POST['priority']) ? sanitize_text_field(wp_unslash($_POST['priority'])) : 'medium',
            'due_date' => isset($_POST['due_date']) && $_POST['due_date'] !== '' ? sanitize_text_field(wp_unslash($_POST['due_date'])) : null,
            'estimated_hours' => isset($_POST['estimated_hours']) && $_POST['estimated_hours'] !== '' ? floatval($_POST['estimated_hours']) : null,
            'assigned_to' => isset($_POST['assigned_to']) ? sanitize_text_field(wp_unslash($_POST['assigned_to'])) : 'freelancer',
            'status' => isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'pending',
            'is_client_visible' => isset($_POST['is_client_visible']) ? intval($_POST['is_client_visible']) : 1,
            'is_recurring' => isset($_POST['is_recurring']) ? intval($_POST['is_recurring']) : 0,
            'recurring_frequency' => isset($_POST['recurring_frequency']) && $_POST['recurring_frequency'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_frequency'])) : null,
            'recurring_end_date' => isset($_POST['recurring_end_date']) && $_POST['recurring_end_date'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_end_date'])) : null,
            'recurring_days' => isset($_POST['recurring_days']) && $_POST['recurring_days'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_days'])) : null,
            'hide_until_due' => isset($_POST['hide_until_due']) ? intval($_POST['hide_until_due']) : 0,
            'is_non_billable' => isset($_POST['is_non_billable']) ? intval($_POST['is_non_billable']) : 0,
        );
        
        if (empty($data['title'])) {
            wp_send_json_error(__('Task title is required', 'freelance-studio'));
        }
        
        // Track whether we're transitioning to completed (for recurring generation)
        $was_completed = false;
        if ($id) {
            $existing_task = FS_Task::get($id);
            $was_completed = $existing_task && $existing_task->status === 'completed';
        }
        
        if ($id) {
            $result = FS_Task::update($id, $data);
            $message = __('Task updated', 'freelance-studio');
        } else {
            // Use create_generic for tasks without a project, create for project tasks
            if (empty($data['project_id'])) {
                $result = FS_Task::create_generic($data);
            } else {
                $result = FS_Task::create($data);
            }
            $id = $result;
            $message = __('Task created', 'freelance-studio');
        }
        
        if ($result) {
            $task = FS_Task::get($id);
            
            // If task was just changed to completed and is recurring, generate next occurrence
            $recurring_generated = false;
            if ($task && !$was_completed && $task->status === 'completed' && $task->is_recurring) {
                $new_id = FS_Task::generate_next_recurring($task);
                $recurring_generated = $new_id ? true : false;
            }
            
            wp_send_json_success(array('message' => $message, 'task' => $task, 'recurring_generated' => $recurring_generated));
        } else {
            wp_send_json_error(__('Failed to save task', 'freelance-studio'));
        }
    }

    /**
     * Delete task
     */
    public function delete_task() {
        $this->verify_nonce();
        $this->verify_capability('delete_posts');
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid task ID', 'freelance-studio'));
        }
        
        // Check if this is part of a recurring series
        $task = FS_Task::get($id);
        if ($task && $task->is_recurring) {
            // Stop the recurring series first to prevent regeneration
            FS_Task::stop_recurring($id);
        }
        
        $result = FS_Task::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Task deleted', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to delete task', 'freelance-studio'));
        }
    }

    /**
     * Archive a task
     */
    public function archive_task() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid task ID', 'freelance-studio'));
        }
        
        // Store the previous status before archiving
        $task = FS_Task::get($id);
        if (!$task) {
            wp_send_json_error(__('Task not found', 'freelance-studio'));
        }
        
        $result = FS_Task::update($id, array(
            'status' => 'archived',
            'previous_status' => $task->status,
        ));
        
        if ($result !== false) {
            wp_send_json_success(array('message' => __('Task archived', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to archive task', 'freelance-studio'));
        }
    }

    /**
     * Restore an archived task
     */
    public function restore_task() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(__('Invalid task ID', 'freelance-studio'));
        }
        
        $task = FS_Task::get($id);
        if (!$task) {
            wp_send_json_error(__('Task not found', 'freelance-studio'));
        }
        
        // Restore to previous status, default to completed if not set
        $restore_status = !empty($task->previous_status) ? $task->previous_status : 'completed';
        
        $result = FS_Task::update($id, array(
            'status' => $restore_status,
            'previous_status' => null,
        ));
        
        if ($result !== false) {
            wp_send_json_success(array(
                'message' => __('Task restored', 'freelance-studio'),
                'status' => $restore_status,
            ));
        } else {
            wp_send_json_error(__('Failed to restore task', 'freelance-studio'));
        }
    }

    /**
     * Archive all completed tasks
     */
    public function archive_completed_tasks() {
        $this->verify_nonce();
        
        global $wpdb;
        $table = $wpdb->prefix . 'fs_tasks';
        
        $result = $wpdb->query(
            "UPDATE {$table} SET previous_status = status, status = 'archived' WHERE status = 'completed'"
        );
        
        wp_send_json_success(array(
            /* translators: 1. count/number */
            'message' => sprintf(__('%d tasks archived', 'freelance-studio'), $result),
            'count' => $result,
        ));
    }

    /**
     * Permanently delete all archived tasks
     */
    public function purge_archived_tasks() {
        $this->verify_nonce();

        global $wpdb;
        $table = $wpdb->prefix . 'fs_tasks';

        $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status = 'archived'" );
        $wpdb->query( "DELETE FROM {$table} WHERE status = 'archived'" );

        wp_send_json_success( array(
            /* translators: 1. count/number */
            'message' => sprintf( __( '%d archived tasks permanently deleted.', 'freelance-studio' ), $count ),
            'count'   => $count,
        ) );
    }

    /**
     * Toggle task completion
     */
    public function toggle_task() {
        $this->verify_nonce();
        
        // Accept either 'id' or 'task_id' for flexibility
        $id = isset($_POST['id']) ? intval($_POST['id']) : (isset($_POST['task_id']) ? intval($_POST['task_id']) : 0);
        
        if (!$id) {
            wp_send_json_error(__('Invalid task ID', 'freelance-studio'));
        }
        
        $result = FS_Task::toggle_complete($id);
        $task = FS_Task::get($id);
        
        if ($result) {
            // If task was just completed and is recurring, generate the next occurrence
            $new_task_id = null;
            if ($task && $task->status === 'completed' && $task->is_recurring) {
                $new_task_id = FS_Task::generate_next_recurring($task);
            }
            
            wp_send_json_success(array(
                'task' => $task,
                'recurring_generated' => $new_task_id ? true : false,
            ));
        } else {
            wp_send_json_error(__('Failed to update task', 'freelance-studio'));
        }
    }

    /**
     * Update task status (for Kanban drag and drop)
     */
    public function update_task_status() {
        $this->verify_nonce();
        
        $task_id = isset($_POST['task_id']) ? intval($_POST['task_id']) : 0;
        $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : '';
        $positions = isset($_POST['positions']) ? $_POST['positions'] : array();
        
        if (!$task_id || !$status) {
            wp_send_json_error(__('Invalid parameters', 'freelance-studio'));
        }
        
        // Validate status
        $valid_statuses = array_keys(FS_Task::get_statuses());
        if (!in_array($status, $valid_statuses)) {
            wp_send_json_error(__('Invalid status', 'freelance-studio'));
        }
        
        // Update task status
        $result = FS_Task::update($task_id, array('status' => $status));
        
        // Update positions for all tasks in the column
        if (!empty($positions) && is_array($positions)) {
            foreach ($positions as $pos_data) {
                if (isset($pos_data['id']) && isset($pos_data['position'])) {
                    FS_Task::update(intval($pos_data['id']), array('position' => intval($pos_data['position'])));
                }
            }
        }
        
        if ($result !== false) {
            $task = FS_Task::get($task_id);
            
            // If task was dragged to completed and is recurring, generate next occurrence
            $recurring_generated = false;
            if ($task && $status === 'completed' && $task->is_recurring) {
                $new_id = FS_Task::generate_next_recurring($task);
                $recurring_generated = $new_id ? true : false;
            }
            
            wp_send_json_success(array(
                'task' => $task,
                'message' => __('Task updated', 'freelance-studio'),
                'recurring_generated' => $recurring_generated,
            ));
        } else {
            wp_send_json_error(__('Failed to update task', 'freelance-studio'));
        }
    }

    /**
     * Save task from Kanban board (supports generic tasks without project)
     */
    public function save_kanban_task() {
        $this->verify_nonce();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        $data = array(
            'project_id' => isset($_POST['project_id']) && $_POST['project_id'] !== '' ? intval($_POST['project_id']) : null,
            'client_id' => isset($_POST['client_id']) && $_POST['client_id'] !== '' ? intval($_POST['client_id']) : null,
            'title' => isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '',
            'description' => isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '',
            'priority' => isset($_POST['priority']) ? sanitize_text_field(wp_unslash($_POST['priority'])) : 'medium',
            'due_date' => isset($_POST['due_date']) && $_POST['due_date'] !== '' ? sanitize_text_field(wp_unslash($_POST['due_date'])) : null,
            'estimated_hours' => isset($_POST['estimated_hours']) && $_POST['estimated_hours'] !== '' ? floatval($_POST['estimated_hours']) : null,
            'assigned_to' => isset($_POST['assigned_to']) ? sanitize_text_field(wp_unslash($_POST['assigned_to'])) : 'freelancer',
            'status' => isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'pending',
            'is_recurring' => isset($_POST['is_recurring']) ? intval($_POST['is_recurring']) : 0,
            'recurring_frequency' => isset($_POST['recurring_frequency']) && $_POST['recurring_frequency'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_frequency'])) : null,
            'recurring_end_date' => isset($_POST['recurring_end_date']) && $_POST['recurring_end_date'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_end_date'])) : null,
            'recurring_days' => isset($_POST['recurring_days']) && $_POST['recurring_days'] !== '' ? sanitize_text_field(wp_unslash($_POST['recurring_days'])) : null,
            'hide_until_due' => isset($_POST['hide_until_due']) ? intval($_POST['hide_until_due']) : 0,
            'is_non_billable' => isset($_POST['is_non_billable']) ? intval($_POST['is_non_billable']) : 0,
        );
        
        if (empty($data['title'])) {
            wp_send_json_error(__('Task title is required', 'freelance-studio'));
        }
        
        // Track whether we're transitioning to completed (for recurring generation)
        $was_completed = false;
        if ($id) {
            $existing_task = FS_Task::get($id);
            $was_completed = $existing_task && $existing_task->status === 'completed';
        }
        
        if ($id) {
            $result = FS_Task::update($id, $data);
            $message = __('Task updated', 'freelance-studio');
        } else {
            $result = FS_Task::create_generic($data);
            $id = $result;
            $message = __('Task created', 'freelance-studio');
        }
        
        if ($result) {
            $task = FS_Task::get($id);
            
            // If task was just changed to completed and is recurring, generate next occurrence
            $recurring_generated = false;
            if ($task && !$was_completed && $task->status === 'completed' && $task->is_recurring) {
                $new_id = FS_Task::generate_next_recurring($task);
                $recurring_generated = $new_id ? true : false;
            }
            
            wp_send_json_success(array('message' => $message, 'task' => $task, 'recurring_generated' => $recurring_generated));
        } else {
            wp_send_json_error(__('Failed to save task', 'freelance-studio'));
        }
    }

    /**
     * Save kanban column label customization
     */
    public function save_kanban_column_label() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : '';
        $label = isset($_POST['label']) ? sanitize_text_field(wp_unslash($_POST['label'])) : '';
        
        // Validate status
        $valid_statuses = array('pending', 'in_progress', 'needs_info', 'with_client', 'completed');
        if (!in_array($status, $valid_statuses)) {
            wp_send_json_error(__('Invalid column status', 'freelance-studio'));
        }
        
        if (empty($label)) {
            wp_send_json_error(__('Column label cannot be empty', 'freelance-studio'));
        }
        
        // Get current labels
        $labels = get_option('fs_kanban_column_labels', array());
        
        // Update the specific label
        $labels[$status] = $label;
        
        // Save back to options
        $result = update_option('fs_kanban_column_labels', $labels);
        
        if ($result !== false) {
            wp_send_json_success(array('message' => __('Column label saved', 'freelance-studio'), 'label' => $label));
        } else {
            wp_send_json_error(__('Failed to save column label', 'freelance-studio'));
        }
    }

    /**
     * Apply project template
     */
    public function apply_project_template() {
        $this->verify_nonce();
        
        $template_id = isset($_POST['template_id']) ? intval($_POST['template_id']) : 0;
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $start_date = isset($_POST['start_date']) ? sanitize_text_field(wp_unslash($_POST['start_date'])) : null;
        
        if (!$template_id || !$project_id) {
            wp_send_json_error(__('Invalid parameters', 'freelance-studio'));
        }
        
        $result = FS_Project_Template::apply_to_project($template_id, $project_id, $start_date);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Template applied successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to apply template', 'freelance-studio'));
        }
    }

    /**
     * Import template from library
     */
    public function import_library_template() {
        $this->verify_nonce();
        
        $slugs = isset($_POST['slugs']) ? array_map('sanitize_text_field', (array) $_POST['slugs']) : array();
        
        if (empty($slugs)) {
            wp_send_json_error(__('No templates selected', 'freelance-studio'));
        }
        
        $results = FS_Template_Library::import_templates($slugs);
        
        $success_count = count($results['success']);
        $error_count = count($results['errors']);
        
        if ($success_count > 0) {
            $message = sprintf(
                /* translators: 1. count/number */
                _n('%d template imported successfully.', '%d templates imported successfully.', $success_count, 'freelance-studio'),
                $success_count
            );
            
            if ($error_count > 0) {
                $message .= ' ' . sprintf(
                    /* translators: 1. count/number */
                    _n('%d template could not be imported (may already exist).', '%d templates could not be imported (may already exist).', $error_count, 'freelance-studio'),
                    $error_count
                );
            }
            
            wp_send_json_success(array(
                'message' => $message,
                'success' => $results['success'],
                'errors' => $results['errors'],
            ));
        } else {
            wp_send_json_error(__('No templates could be imported. They may already exist.', 'freelance-studio'));
        }
    }

    /**
     * Client portal - toggle task completion
     * This is a public (nopriv) action for client portal users
     */
    public function client_toggle_task() {
        $task_id = isset($_POST['task_id']) ? intval($_POST['task_id']) : 0;
        $portal_token = isset($_POST['portal_token']) ? sanitize_text_field(wp_unslash($_POST['portal_token'])) : '';
        
        if (!$task_id || !$portal_token) {
            wp_send_json_error(__('Invalid request', 'freelance-studio'));
        }
        
        // Verify the portal token is valid against the clients table
        global $wpdb;
        $client = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}fs_clients WHERE portal_token = %s AND portal_enabled = 1",
            $portal_token
        ));
        
        if (!$client) {
            wp_send_json_error(__('Invalid or expired portal access', 'freelance-studio'));
        }
        
        // Get the task
        $task = FS_Task::get($task_id);
        
        if (!$task) {
            wp_send_json_error(__('Task not found', 'freelance-studio'));
        }
        
        // Verify the task belongs to a project owned by this client
        $project = FS_Project::get($task->project_id);
        
        if (!$project || $project->client_id != $client->id) {
            wp_send_json_error(__('Access denied', 'freelance-studio'));
        }
        
        // Verify the task is assigned to the client
        if (($task->assigned_to ?? 'freelancer') !== 'client') {
            wp_send_json_error(__('You can only update tasks assigned to you', 'freelance-studio'));
        }
        
        // Toggle the task
        $result = FS_Task::toggle_complete($task_id);
        
        if ($result) {
            $updated_task = FS_Task::get($task_id);
            wp_send_json_success(array('task' => $updated_task));
        } else {
            wp_send_json_error(__('Failed to update task', 'freelance-studio'));
        }
    }

    /**
     * Client adds/requests a task from the portal (public AJAX)
     */
    public function client_add_task() {
        $portal_token = isset($_POST['portal_token']) ? sanitize_text_field(wp_unslash($_POST['portal_token'])) : '';
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
        $description = isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '';
        $priority = isset($_POST['priority']) ? sanitize_text_field(wp_unslash($_POST['priority'])) : 'medium';
        $due_date = isset($_POST['due_date']) && $_POST['due_date'] !== '' ? sanitize_text_field(wp_unslash($_POST['due_date'])) : null;
        
        if (!$portal_token || !$title) {
            wp_send_json_error(__('Missing required fields', 'freelance-studio'));
        }
        
        // Verify portal token
        global $wpdb;
        $client = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}fs_clients WHERE portal_token = %s AND portal_enabled = 1",
            $portal_token
        ));
        
        if (!$client) {
            wp_send_json_error(__('Invalid portal access', 'freelance-studio'));
        }
        
        // Verify project belongs to this client (if project specified)
        if ($project_id) {
            $project = FS_Project::get($project_id);
            if (!$project || $project->client_id != $client->id) {
                wp_send_json_error(__('Invalid project', 'freelance-studio'));
            }
        }
        
        // Validate priority
        $valid_priorities = array_keys(FS_Task::get_priorities());
        if (!in_array($priority, $valid_priorities)) {
            $priority = 'medium';
        }
        
        // Create the task with 'requested' status
        $data = array(
            'title' => $title,
            'description' => $description,
            'priority' => $priority,
            'due_date' => $due_date,
            'status' => 'requested',
            'assigned_to' => 'freelancer',
            'is_client_visible' => 1,
        );
        
        if ($project_id) {
            $data['project_id'] = $project_id;
            $result = FS_Task::create($data);
        } else {
            $data['client_id'] = $client->id;
            $result = FS_Task::create_generic($data);
        }
        
        if ($result) {
            // Send notification email to freelancer
            $task = FS_Task::get($result);
            FS_Email_Service::send_task_request_notification($task, $client);
            
            wp_send_json_success(array(
                'message' => __('Your request has been submitted!', 'freelance-studio'),
                'task' => $task,
            ));
        } else {
            wp_send_json_error(__('Failed to submit request', 'freelance-studio'));
        }
    }

    /**
     * Accept a task request (admin AJAX)
     */
    public function accept_task_request() {
        $this->verify_nonce();
        
        $task_id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$task_id) {
            wp_send_json_error(__('Invalid task ID', 'freelance-studio'));
        }
        
        $task = FS_Task::get($task_id);
        
        if (!$task) {
            wp_send_json_error(__('Task not found', 'freelance-studio'));
        }
        
        if ($task->status !== 'requested') {
            wp_send_json_error(__('This task is not a pending request', 'freelance-studio'));
        }
        
        // Update status to pending
        $result = FS_Task::update($task_id, array('status' => 'pending'));
        
        if ($result) {
            // Get client info for the email
            $client_id = $task->resolved_client_id ?: $task->client_id;
            if (!$client_id && $task->project_id) {
                $project = FS_Project::get($task->project_id);
                $client_id = $project ? $project->client_id : null;
            }
            
            if ($client_id) {
                $client = FS_Client::get($client_id);
                if ($client) {
                    FS_Email_Service::send_task_request_accepted($task, $client);
                }
            }
            
            wp_send_json_success(array(
                'message' => __('Request accepted', 'freelance-studio'),
            ));
        } else {
            wp_send_json_error(__('Failed to accept request', 'freelance-studio'));
        }
    }

    /**
     * Send a payment reminder for an invoice
     */
    public function send_reminder() {
        $this->verify_nonce();
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        $custom_message = isset($_POST['custom_message']) ? sanitize_textarea_field(wp_unslash($_POST['custom_message'])) : '';
        
        if (!$invoice_id) {
            wp_send_json_error(__('Invalid invoice ID', 'freelance-studio'));
        }
        
        $invoice = FS_Invoice::get($invoice_id);
        if (!$invoice) {
            wp_send_json_error(__('Invoice not found', 'freelance-studio'));
        }
        
        $client = FS_Client::get($invoice->client_id);
        if (!$client) {
            wp_send_json_error(__('Client not found', 'freelance-studio'));
        }
        
        // Resolve recipients from saved send_to_contacts (same contacts the invoice was sent to)
        $to_info = null;
        $cc_emails = array();
        
        if (!empty($invoice->send_to_contacts)) {
            $saved = json_decode($invoice->send_to_contacts, true);
            
            if ($saved && isset($saved['to'])) {
                $to_info = FS_Email_Service::resolve_contact_info($client, $saved['to']);
                if (!empty($saved['cc'])) {
                    foreach ($saved['cc'] as $cc_id) {
                        $cc_info = FS_Email_Service::resolve_contact_info($client, $cc_id);
                        if ($cc_info && (!$to_info || $cc_info['email'] !== $to_info['email'])) {
                            $cc_emails[] = $cc_info['email'];
                        }
                    }
                }
            } elseif (is_array($saved)) {
                foreach ($saved as $contact_id) {
                    $info = FS_Email_Service::resolve_contact_info($client, $contact_id);
                    if ($info) {
                        if (!$to_info) {
                            $to_info = $info;
                        } elseif ($info['email'] !== $to_info['email']) {
                            $cc_emails[] = $info['email'];
                        }
                    }
                }
            }
        }
        
        // Fall back to primary client email
        if (!$to_info) {
            $to_info = FS_Email_Service::resolve_contact_info($client, 'primary');
        }
        
        if (!$to_info || empty($to_info['email'])) {
            wp_send_json_error(__('No valid recipient email found', 'freelance-studio'));
        }
        
        // Generate view token if not exists
        if (empty($invoice->view_token)) {
            global $wpdb;
            $view_token = wp_generate_password(32, false);
            $wpdb->update(
                $wpdb->prefix . 'fs_invoices',
                array('view_token' => $view_token),
                array('id' => $invoice->id)
            );
            $invoice->view_token = $view_token;
        }
        
        // Check if due date exists
        if (empty($invoice->due_date)) {
            wp_send_json_error(__('Invoice has no due date set', 'freelance-studio'));
        }
        
        // Determine if overdue
        $is_overdue = strtotime($invoice->due_date) < strtotime(current_time('Y-m-d'));
        
        $subject_template = $is_overdue 
            ? get_option('fs_reminder_subject_overdue', 'Invoice {invoice_number} is overdue')
            : get_option('fs_reminder_subject_before', 'Friendly reminder: Invoice {invoice_number} due soon');
        
        $subject = str_replace(
            array('{invoice_number}', '{client_name}', '{amount}', '{due_date}'),
            array(
                $invoice->invoice_number,
                $client->company_name ?: $client->name,
                FS_View_Context::format_currency($invoice->balance_due),
                gmdate('F j, Y', strtotime($invoice->due_date))
            ),
            $subject_template
        );
        
        $invoice_url = home_url('?fs_invoice=' . $invoice->view_token);
        $settings = array(
            'business_name' => get_option('fs_business_name', ''),
            'business_email' => get_option('fs_business_email', ''),
        );
        
        // Build HTML email
        $client_name = $client->company_name ?: $client->name;
        if ($is_overdue) {
            $email_body = sprintf(
                '<p>This is a reminder that Invoice %s is past due.</p>
                <p><strong>Amount Due:</strong> %s<br>
                <strong>Due Date:</strong> %s</p>
                <p>Please submit payment at your earliest convenience.</p>
                <p><a href="%s" style="display:inline-block;padding:10px 20px;background:#2271b1;color:#fff;text-decoration:none;border-radius:4px;">View Invoice</a></p>',
                $invoice->invoice_number,
                FS_View_Context::format_currency($invoice->balance_due),
                gmdate('F j, Y', strtotime($invoice->due_date)),
                $invoice_url
            );
        } else {
            $email_body = sprintf(
                '<p>This is a friendly reminder that Invoice %s will be due soon.</p>
                <p><strong>Amount Due:</strong> %s<br>
                <strong>Due Date:</strong> %s</p>
                <p><a href="%s" style="display:inline-block;padding:10px 20px;background:#2271b1;color:#fff;text-decoration:none;border-radius:4px;">View Invoice</a></p>',
                $invoice->invoice_number,
                FS_View_Context::format_currency($invoice->balance_due),
                gmdate('F j, Y', strtotime($invoice->due_date)),
                $invoice_url
            );
        }
        
        if (!empty($custom_message)) {
            $email_body .= '<p>' . nl2br(esc_html($custom_message)) . '</p>';
        }
        
        // Wrap in simple HTML template
        $message = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;line-height:1.6;color:#333;">' . $email_body . '</body></html>';
        
        // Set headers
        $headers = array('Content-Type: text/html; charset=UTF-8');
        if (!empty($settings['business_email'])) {
            $from_name = !empty($settings['business_name']) ? $settings['business_name'] : get_bloginfo('name');
            $headers[] = 'From: ' . $from_name . ' <' . $settings['business_email'] . '>';
            $headers[] = 'Reply-To: ' . $from_name . ' <' . $settings['business_email'] . '>';
        }
        
        // Add CC headers for additional recipients
        foreach ($cc_emails as $cc_email) {
            $headers[] = 'Cc: ' . $cc_email;
        }
        
        $sent = wp_mail($to_info['email'], $subject, $message, $headers);
        
        if ($sent) {
            // Log the email
            FS_Email_Service::log_email($to_info['email'], $subject, 'payment_reminder', $invoice->id);
            wp_send_json_success(array('message' => __('Reminder sent successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(__('Failed to send reminder', 'freelance-studio'));
        }
    }

    /**
     * Schedule reminders for an invoice
     */
    public function schedule_reminders() {
        $this->verify_nonce();
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        
        if (!$invoice_id) {
            wp_send_json_error(__('Invalid invoice ID', 'freelance-studio'));
        }
        
        $reminders = FS_Payment_Reminder::schedule_for_invoice($invoice_id);
        
        if ($reminders) {
            wp_send_json_success(array(
                /* translators: 1. count/number */
                'message' => sprintf(__('%d reminders scheduled', 'freelance-studio'), count($reminders)),
                'reminder_ids' => $reminders
            ));
        } else {
            wp_send_json_error(__('Failed to schedule reminders', 'freelance-studio'));
        }
    }

    /**
     * Get unbilled expenses for a project
     */
    public function get_project_expenses() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_success(array('expenses' => array(), 'message' => __('No project selected', 'freelance-studio')));
        }
        
        // Get unbilled expenses for this project
        $expenses = FS_Expense::get_unbilled_by_project($project_id);
        
        // If editing an invoice, include expenses already on this invoice
        $included_ids = array();
        if ($invoice_id) {
            $invoice = FS_Invoice::get($invoice_id);
            if ($invoice && !empty($invoice->expenses)) {
                $invoice_expenses = is_string($invoice->expenses) ? json_decode($invoice->expenses, true) : $invoice->expenses;
                if (is_array($invoice_expenses)) {
                    $included_ids = array_column($invoice_expenses, 'id');
                }
            }
        }
        
        // Format expenses for response
        $formatted = array();
        foreach ($expenses as $exp) {
            $formatted[] = array(
                'id' => $exp->id,
                'date' => $exp->expense_date,
                'date_formatted' => gmdate('M j, Y', strtotime($exp->expense_date)),
                'description' => $exp->expense_name,
                'category' => $exp->category,
                'amount' => floatval($exp->amount),
                'amount_formatted' => '$' . number_format($exp->amount, 2),
                'included' => in_array($exp->id, $included_ids),
            );
        }
        
        wp_send_json_success(array(
            'expenses' => $formatted,
            'count' => count($formatted)
        ));
    }

    /**
     * Quick add task from dashboard
     */
    public function quick_add_task() {
        $this->verify_nonce();
        
        $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : null;
        
        if (empty($title)) {
            wp_send_json_error(__('Task title is required', 'freelance-studio'));
        }
        
        $task_id = FS_Task::create(array(
            'title' => $title,
            'project_id' => $project_id ?: null,
            'status' => 'pending',
            'priority' => 'medium',
        ));
        
        if ($task_id) {
            $task = FS_Task::get($task_id);
            wp_send_json_success(array(
                'task' => $task,
                'message' => __('Task added successfully', 'freelance-studio')
            ));
        } else {
            wp_send_json_error(__('Failed to create task', 'freelance-studio'));
        }
    }

    /**
     * Quick start timer from dashboard
     */
    public function quick_start_timer() {
        $this->verify_nonce();
        
        // Live timer is a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( __( 'The live timer is available with Freelance Studio Pro.', 'freelance-studio' ) );
        }
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : null;
        $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
        $activity = isset($_POST['activity']) ? sanitize_text_field(wp_unslash($_POST['activity'])) : 'development';
        
        // Check if there's already a running timer
        $running = FS_Time_Entry::get_running();
        if ($running) {
            wp_send_json_error(__('A timer is already running. Please stop it first.', 'freelance-studio'));
        }
        
        // Get hourly rate from project's hourly services or system default
        $hourly_rate = get_option('fs_default_hourly_rate', 0);
        if ($project_id) {
            $project = FS_Project::get($project_id);
            if ($project) {
                $services = $project->services;
                if (is_string($services)) {
                    $services = json_decode($services, true);
                }
                if (!empty($services) && is_array($services)) {
                    foreach ($services as $svc) {
                        if (isset($svc['pricing_type']) && $svc['pricing_type'] === 'hourly' && !empty($svc['rate'])) {
                            $hourly_rate = floatval($svc['rate']);
                            break;
                        }
                    }
                }
            }
        }
        
        $entry_id = FS_Time_Entry::create(array(
            'project_id' => $project_id ?: null,
            'description' => $description,
            'activity' => $activity,
            'start_time' => current_time('mysql'),
            'hourly_rate' => $hourly_rate,
            'is_running' => 1,
        ));
        
        if ($entry_id) {
            $entry = FS_Time_Entry::get($entry_id);
            wp_send_json_success(array(
                'entry' => $entry,
                'message' => __('Timer started', 'freelance-studio')
            ));
        } else {
            wp_send_json_error(__('Failed to start timer', 'freelance-studio'));
        }
    }

    /**
     * Full sync to Google Calendar
     */
    public function gcal_full_sync() {
        $this->verify_nonce();
        
        $result = FS_Google_Calendar_Service::full_sync();
        
        if ($result['success']) {
            wp_send_json_success(array(
                'message' => __('Calendar sync completed', 'freelance-studio'),
                'synced' => $result['synced'],
            ));
        } else {
            wp_send_json_error(array('message' => $result['message']));
        }
    }

    /**
     * Sync changes from Google Calendar (inbound sync)
     */
    public function gcal_inbound_sync() {
        $this->verify_nonce();
        
        $result = FS_Google_Calendar_Service::manual_inbound_sync();
        
        if ($result['success']) {
            $results = $result['results'];
            $message = sprintf(
                /* translators: 1. events processed, 2. events updated, 3. events cancelled */
                __('Sync complete: %1$d events processed, %2$d updated, %3$d cancelled', 'freelance-studio'),
                $results['processed'],
                $results['updated'],
                $results['cancelled']
            );
            wp_send_json_success(array(
                'message' => $message,
                'results' => $results,
            ));
        } else {
            wp_send_json_error(array('message' => $result['message']));
        }
    }

    /**
     * Preview weekly digest
     */
    public function gcal_preview_digest() {
        $this->verify_nonce();
        
        // Use demo data for preview
        $digest = FS_Google_Calendar_Service::get_demo_digest();
        
        // Generate HTML using the same method as actual email
        ob_start();
        $this->render_digest_preview($digest);
        $html = ob_get_clean();
        
        wp_send_json_success(array('html' => $html));
    }

    /**
     * Render digest preview HTML
     */
    private function render_digest_preview($digest) {
        $start_date = gmdate('l, F j');
        $end_date = gmdate('l, F j', strtotime('+7 days'));
        ?>
        <div style="background: #F5F7FA; padding: 20px; border-radius: 8px;">
            <div style="background: linear-gradient(90deg, #20bba6, #77d5c9 37.9%, #77d5c9 67%, #d2e767 100%); padding: 20px; border-radius: 8px 8px 0 0; text-align: center;">
                <h2 style="margin: 0; color: #374151;">Your Week Ahead</h2>
                <p style="color: #374151; opacity: 0.7; margin: 10px 0 0 0;"><?php echo  esc_html( $start_date ); ?> - <?php echo  esc_html( $end_date ); ?></p>
            </div>
            
            <div style="background: #fff; padding: 20px; border-radius: 0 0 8px 8px;">
                
                <?php if (!empty($digest['invoices'])): ?>
                <div style="margin-bottom: 20px;">
                    <h3 style="color: #374151; border-bottom: 2px solid #77D5C9; padding-bottom: 8px;">
                        Invoices Due (<?php echo count($digest['invoices']); ?>)
                    </h3>
                    <?php foreach ($digest['invoices'] as $invoice): ?>
                        <div style="background: #FAFBFC; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid #FFA26E;">
                            <strong style="color: #374151;">Invoice #<?php echo esc_html($invoice->invoice_number); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($invoice->due_date)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($invoice->company_name ?: $invoice->client_name); ?></span>
                            <span style="float: right; color: #1FBBA6; font-weight: 600;">$<?php echo number_format($invoice->balance_due, 2); ?></span>
                        </div>
                    <?php endforeach; ?>
                </div>
                <?php endif; ?>
                
                <?php if (!empty($digest['projects'])): ?>
                <div style="margin-bottom: 20px;">
                    <h3 style="color: #374151; border-bottom: 2px solid #77D5C9; padding-bottom: 8px;">
                        Project Deadlines (<?php echo count($digest['projects']); ?>)
                    </h3>
                    <?php foreach ($digest['projects'] as $project): ?>
                        <div style="background: #FAFBFC; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid #81A5DF;">
                            <strong style="color: #374151;"><?php echo esc_html($project->name); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($project->due_date)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($project->company_name ?: $project->client_name); ?></span>
                        </div>
                    <?php endforeach; ?>
                </div>
                <?php endif; ?>
                
                <?php if (!empty($digest['milestones'])): ?>
                <div style="margin-bottom: 20px;">
                    <h3 style="color: #374151; border-bottom: 2px solid #77D5C9; padding-bottom: 8px;">
                        Milestones Due (<?php echo count($digest['milestones']); ?>)
                    </h3>
                    <?php foreach ($digest['milestones'] as $milestone): ?>
                        <div style="background: #FAFBFC; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid #D2E768;">
                            <strong style="color: #374151;"><?php echo esc_html($milestone->name); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($milestone->due_date)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($milestone->project_name); ?></span>
                        </div>
                    <?php endforeach; ?>
                </div>
                <?php endif; ?>
                
                <?php if (!empty($digest['tasks'])): ?>
                <div style="margin-bottom: 20px;">
                    <h3 style="color: #374151; border-bottom: 2px solid #77D5C9; padding-bottom: 8px;">
                        Tasks Due (<?php echo count($digest['tasks']); ?>)
                    </h3>
                    <?php foreach ($digest['tasks'] as $task): 
                        $priority_color = '#9CA3AF';
                        if ($task->priority === 'urgent') $priority_color = '#E85A5A';
                        elseif ($task->priority === 'high') $priority_color = '#FFA26E';
                        
                        $status_label = ucfirst(str_replace('_', ' ', $task->status));
                        $status_color = '#81A5DF';
                        if ($task->status === 'todo') $status_color = '#9CA3AF';
                        elseif ($task->status === 'in_progress') $status_color = '#81A5DF';
                    ?>
                        <div style="background: #FAFBFC; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid <?php echo  esc_attr( $priority_color ); ?>;">
                            <strong style="color: #374151;"><?php echo esc_html($task->title); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($task->due_date)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($task->project_name); ?></span>
                            <span style="display: inline-block; background: <?php echo  esc_attr( $status_color ); ?>; color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 8px; vertical-align: middle;"><?php echo esc_html($status_label); ?></span>
                        </div>
                    <?php endforeach; ?>
                </div>
                <?php endif; ?>
                
                <?php if (!empty($digest['proposals']) || !empty($digest['contracts'])): ?>
                <div style="margin-bottom: 20px;">
                    <h3 style="color: #374151; border-bottom: 2px solid #77D5C9; padding-bottom: 8px;">
                        Expiring Soon
                    </h3>
                    <?php foreach ($digest['proposals'] as $proposal): ?>
                        <div style="background: #FFF0F0; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid #E85A5A;">
                            <strong style="color: #374151;">Proposal: <?php echo esc_html($proposal->title); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($proposal->valid_until)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($proposal->company_name ?: $proposal->client_name); ?></span>
                        </div>
                    <?php endforeach; ?>
                    <?php foreach ($digest['contracts'] as $contract): ?>
                        <div style="background: #FFF0F0; padding: 10px 12px; border-radius: 6px; margin-bottom: 8px; border-left: 4px solid #E85A5A;">
                            <strong style="color: #374151;">Contract: <?php echo esc_html($contract->title); ?></strong>
                            <span style="float: right; color: #6B7280;"><?php echo  esc_html( gmdate('M j', strtotime($contract->valid_until)) ); ?></span>
                            <br>
                            <span style="color: #6B7280; font-size: 13px;"><?php echo esc_html($contract->company_name ?: $contract->client_name); ?></span>
                        </div>
                    <?php endforeach; ?>
                </div>
                <?php endif; ?>
                
            </div>
            
            <p style="text-align: center; color: #9CA3AF; font-size: 12px; margin-top: 15px;">
                This is a preview with sample data. Your actual digest will show real data from your projects and invoices.
            </p>
        </div>
        <?php
    }

    /**
     * Send test weekly digest email
     */
    public function gcal_send_test_digest() {
        $this->verify_nonce();
        
        $business_email = get_option('fs_business_email', get_option('admin_email'));
        
        if (empty($business_email)) {
            wp_send_json_error(array('message' => __('No business email configured', 'freelance-studio')));
        }
        
        // Use demo data for test email
        $digest = FS_Google_Calendar_Service::get_demo_digest();
        
        // Build email HTML (reuse the service method pattern)
        ob_start();
        ?>
        <!DOCTYPE html>
        <html>
        <head><meta charset="UTF-8"></head>
        <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
        <?php $this->render_digest_preview($digest); ?>
        </body>
        </html>
        <?php
        $html = ob_get_clean();
        
        $subject = '[TEST] Your Week Ahead - Freelance Studio Digest';
        $headers = array('Content-Type: text/html; charset=UTF-8');
        
        $sent = wp_mail($business_email, $subject, $html, $headers);
        
        if ($sent) {
            wp_send_json_success(array(
                /* translators: 1. sent to value */
                'message' => sprintf(__('Test email sent to %s', 'freelance-studio'), $business_email)
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to send test email', 'freelance-studio')));
        }
    }

    /**
     * Create a custom event on Google Calendar
     */
    public function gcal_create_event() {
        $this->verify_nonce();
        
        if (!FS_Google_Calendar_Service::is_connected()) {
            wp_send_json_error(array('message' => __('Google Calendar not connected', 'freelance-studio')));
        }
        
        $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
        $date = isset($_POST['date']) ? sanitize_text_field(wp_unslash($_POST['date'])) : '';
        $end_date = isset($_POST['end_date']) ? sanitize_text_field(wp_unslash($_POST['end_date'])) : '';
        $description = isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '';
        $all_day = isset($_POST['all_day']) && ($_POST['all_day'] === 'true' || $_POST['all_day'] === true || $_POST['all_day'] === '1');
        $start_time = isset($_POST['start_time']) ? sanitize_text_field(wp_unslash($_POST['start_time'])) : '';
        $end_time = isset($_POST['end_time']) ? sanitize_text_field(wp_unslash($_POST['end_time'])) : '';
        
        error_log('FS Calendar: Creating event - Title: ' . $title . ', Date: ' . $date . ', All Day: ' . ($all_day ? 'yes' : 'no'));
        
        if (empty($title) || empty($date)) {
            wp_send_json_error(array('message' => __('Title and date are required', 'freelance-studio')));
        }
        
        // Build event data
        $event_data = array(
            'summary' => $title,
            'description' => $description,
            'extendedProperties' => array(
                'private' => array(
                    'fs_type' => 'custom',
                ),
            ),
        );
        
        if ($all_day) {
            // All-day event uses date format - end date must be day after for Google Calendar
            $event_data['start'] = array('date' => $date);
            $event_data['end'] = array('date' => $end_date ?: gmdate('Y-m-d', strtotime($date . ' +1 day')));
        } else {
            // Timed event uses dateTime format
            $timezone = wp_timezone_string();
            $start_datetime = $date . 'T' . ($start_time ?: '09:00') . ':00';
            $end_datetime = ($end_date ?: $date) . 'T' . ($end_time ?: '10:00') . ':00';
            
            $event_data['start'] = array('dateTime' => $start_datetime, 'timeZone' => $timezone);
            $event_data['end'] = array('dateTime' => $end_datetime, 'timeZone' => $timezone);
        }
        
        error_log('FS Calendar: Event data - ' . print_r($event_data, true));
        
        $event_id = FS_Google_Calendar_Service::upsert_event($event_data);
        
        error_log('FS Calendar: Event created with ID: ' . ($event_id ?: 'FAILED'));
        
        if ($event_id) {
            wp_send_json_success(array(
                'message' => __('Event created successfully', 'freelance-studio'),
                'event_id' => $event_id,
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to create event', 'freelance-studio')));
        }
    }

    /**
     * Update a Google Calendar event
     */
    public function gcal_update_event() {
        $this->verify_nonce();
        
        if (!FS_Google_Calendar_Service::is_connected()) {
            wp_send_json_error(array('message' => __('Google Calendar not connected', 'freelance-studio')));
        }
        
        $event_id = isset($_POST['event_id']) ? sanitize_text_field(wp_unslash($_POST['event_id'])) : '';
        $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
        $date = isset($_POST['date']) ? sanitize_text_field(wp_unslash($_POST['date'])) : '';
        $end_date = isset($_POST['end_date']) ? sanitize_text_field(wp_unslash($_POST['end_date'])) : '';
        $description = isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '';
        $all_day = isset($_POST['all_day']) && ($_POST['all_day'] === 'true' || $_POST['all_day'] === true || $_POST['all_day'] === '1');
        $start_time = isset($_POST['start_time']) ? sanitize_text_field(wp_unslash($_POST['start_time'])) : '';
        $end_time = isset($_POST['end_time']) ? sanitize_text_field(wp_unslash($_POST['end_time'])) : '';
        
        if (empty($event_id) || empty($title) || empty($date)) {
            wp_send_json_error(array('message' => __('Event ID, title and date are required', 'freelance-studio')));
        }
        
        // Build event data
        $event_data = array(
            'summary' => $title,
            'description' => $description,
            'extendedProperties' => array(
                'private' => array(
                    'fs_type' => 'custom',
                ),
            ),
        );
        
        if ($all_day) {
            $event_data['start'] = array('date' => $date);
            $event_data['end'] = array('date' => $end_date ?: gmdate('Y-m-d', strtotime($date . ' +1 day')));
        } else {
            $timezone = wp_timezone_string();
            $start_datetime = $date . 'T' . ($start_time ?: '09:00') . ':00';
            $end_datetime = ($end_date ?: $date) . 'T' . ($end_time ?: '10:00') . ':00';
            
            $event_data['start'] = array('dateTime' => $start_datetime, 'timeZone' => $timezone);
            $event_data['end'] = array('dateTime' => $end_datetime, 'timeZone' => $timezone);
        }
        
        $result = FS_Google_Calendar_Service::upsert_event($event_data, $event_id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Event updated successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to update event', 'freelance-studio')));
        }
    }

    /**
     * Delete a Google Calendar event
     */
    public function gcal_delete_event() {
        $this->verify_nonce();
        
        if (!FS_Google_Calendar_Service::is_connected()) {
            wp_send_json_error(array('message' => __('Google Calendar not connected', 'freelance-studio')));
        }
        
        $event_id = isset($_POST['event_id']) ? sanitize_text_field(wp_unslash($_POST['event_id'])) : '';
        
        if (empty($event_id)) {
            wp_send_json_error(array('message' => __('Event ID is required', 'freelance-studio')));
        }
        
        $result = FS_Google_Calendar_Service::delete_event($event_id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Event deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete event', 'freelance-studio')));
        }
    }

    /**
     * Get Google Calendar events for a date range (for display in plugin calendar)
     */
    public function gcal_get_events() {
        $this->verify_nonce();
        
        if (!FS_Google_Calendar_Service::is_connected()) {
            wp_send_json_success(array('events' => array(), 'connected' => false));
        }
        
        $start_date = isset($_POST['start_date']) ? sanitize_text_field(wp_unslash($_POST['start_date'])) : gmdate('Y-m-01');
        $end_date = isset($_POST['end_date']) ? sanitize_text_field(wp_unslash($_POST['end_date'])) : gmdate('Y-m-t');
        
        $events = FS_Google_Calendar_Service::get_events_in_range($start_date, $end_date);
        
        wp_send_json_success(array('events' => $events, 'connected' => true));
    }

    /**
     * Update project services
     */
    public function update_project_services() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $services = isset($_POST['services']) ? $_POST['services'] : array();
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        // Sanitize services
        $sanitized_services = array();
        foreach ($services as $service) {
            $svc_data = array(
                'name' => sanitize_text_field($service['name'] ?? ''),
                'description' => sanitize_textarea_field($service['description'] ?? ''),
                'pricing_type' => sanitize_text_field($service['pricing_type'] ?? 'fixed'),
                'hourly_source' => sanitize_text_field($service['hourly_source'] ?? 'estimated'),
                'activity' => sanitize_text_field($service['activity'] ?? ''),
                'rate' => floatval($service['rate'] ?? 0),
                'quantity' => floatval($service['quantity'] ?? 1),
                'amount' => floatval($service['amount'] ?? 0),
            );
            
            // Include retainer fields
            if ($svc_data['pricing_type'] === 'retainer') {
                $svc_data['retainer_amount'] = floatval($service['retainer_amount'] ?? 0);
                $svc_data['retainer_hours'] = floatval($service['retainer_hours'] ?? 0);
                $svc_data['retainer_rollover'] = isset($service['retainer_rollover']) ? 1 : 0;
                $svc_data['retainer_overage_rate'] = floatval($service['retainer_overage_rate'] ?? 0);
            }
            
            // Include hour cap fields
            if ($svc_data['pricing_type'] === 'hourly' && $svc_data['hourly_source'] === 'tracked') {
                $cap_enabled = !empty($service['hour_cap_enabled']);
                $svc_data['hour_cap'] = $cap_enabled ? floatval($service['hour_cap'] ?? 0) : 0;
                $svc_data['hour_cap_overage_rate'] = $cap_enabled ? floatval($service['hour_cap_overage_rate'] ?? 0) : 0;
                $svc_data['hour_cap_notify_client'] = $cap_enabled && !empty($service['hour_cap_notify_client']) ? 1 : 0;
                $svc_data['hour_cap_notify_contact_id'] = $cap_enabled && !empty($service['hour_cap_notify_contact_id']) ? sanitize_text_field($service['hour_cap_notify_contact_id']) : 'primary';
                $svc_data['hour_cap_notify_freelancer'] = $cap_enabled && !empty($service['hour_cap_notify_freelancer']) ? 1 : 0;
                $warn_raw = isset($service['hour_cap_warn_threshold']) ? intval($service['hour_cap_warn_threshold']) : 0;
                $svc_data['hour_cap_warn_threshold'] = ($cap_enabled && $warn_raw >= 1 && $warn_raw <= 99) ? $warn_raw : 0;
            }
            
            $sanitized_services[] = $svc_data;
        }
        
        $result = FS_Project::update_services($project_id, $sanitized_services);
        
        if ($result) {
            $project = FS_Project::get($project_id);
            wp_send_json_success(array(
                'message' => __('Services updated', 'freelance-studio'),
                'total_amount' => $project->total_amount,
                'formatted_total' => FS_View_Context::format_currency($project->total_amount)
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to update services', 'freelance-studio')));
        }
    }

    /**
     * Update project billing schedule
     */
    public function update_project_billing_schedule() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        $data = array(
            'billing_frequency' => isset($_POST['billing_frequency']) ? sanitize_text_field(wp_unslash($_POST['billing_frequency'])) : null,
            'start_date' => isset($_POST['start_date']) ? sanitize_text_field(wp_unslash($_POST['start_date'])) : null,
            'due_date' => isset($_POST['due_date']) ? sanitize_text_field(wp_unslash($_POST['due_date'])) : null,
            'first_invoice_date' => isset($_POST['first_invoice_date']) ? sanitize_text_field(wp_unslash($_POST['first_invoice_date'])) : null,
        );
        
        $result = FS_Project::update_billing_schedule($project_id, $data);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Billing schedule updated', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to update billing schedule', 'freelance-studio')));
        }
    }

    /**
     * Update project deposit settings
     */
    public function update_project_deposit() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        $data = array(
            'deposit_type' => isset($_POST['deposit_type']) ? sanitize_text_field(wp_unslash($_POST['deposit_type'])) : 'percentage',
            'deposit_percentage' => isset($_POST['deposit_percentage']) ? floatval($_POST['deposit_percentage']) : 0,
            'deposit_amount' => isset($_POST['deposit_amount']) ? floatval($_POST['deposit_amount']) : 0,
            'deposit_paid' => isset($_POST['deposit_paid']) ? intval($_POST['deposit_paid']) : 0,
        );
        
        $result = FS_Project::update_deposit($project_id, $data);
        
        if ($result) {
            $project = FS_Project::get($project_id);
            wp_send_json_success(array(
                'message' => __('Deposit settings updated', 'freelance-studio'),
                'deposit_amount' => $project->deposit_amount,
                'formatted_deposit' => FS_View_Context::format_currency($project->deposit_amount)
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to update deposit settings', 'freelance-studio')));
        }
    }
    
    /**
     * Update project notes inline
     */
    public function update_project_notes() {
        $this->verify_nonce();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        $notes = isset($_POST['notes']) ? wp_kses_post(wp_unslash($_POST['notes'])) : '';
        
        $result = FS_Project::update_notes($project_id, $notes);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Notes saved', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to save notes', 'freelance-studio')));
        }
    }
    
    /**
     * Auto-save settings via AJAX
     */
    public function auto_save_settings() {
        check_ajax_referer('fs_auto_save_settings', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __('Permission denied', 'freelance-studio')));
        }
        
        $field = isset($_POST['field']) ? sanitize_text_field(wp_unslash($_POST['field'])) : '';
        $value = isset($_POST['value']) ? $_POST['value'] : '';
        
        if (empty($field)) {
            wp_send_json_error(array('message' => __('No field specified', 'freelance-studio')));
        }
        
        // Map form field names to option names
        $field_map = array(
            // Business Information
            'business_name' => 'fs_business_name',
            'business_email' => 'fs_business_email',
            'business_phone' => 'fs_business_phone',
            'business_website' => 'fs_business_website',
            'my_role' => 'fs_my_role',
            'booking_type' => 'fs_booking_type',
            'booking_url' => 'fs_booking_url',
            // Business Address
            'business_address' => 'fs_business_address',
            'business_city' => 'fs_business_city',
            'business_state' => 'fs_business_state',
            'business_zip' => 'fs_business_zip',
            'business_country' => 'fs_business_country',
            // Branding
            'business_logo' => 'fs_business_logo',
            'business_logo_light' => 'fs_business_logo_light',
            'invoice_primary_color' => 'fs_invoice_primary_color',
            'invoice_primary_color_mode' => 'fs_invoice_primary_color_mode',
            'invoice_primary_color_2' => 'fs_invoice_primary_color_2',
            'invoice_gradient_direction' => 'fs_invoice_gradient_direction',
            // Invoice Settings
            'invoice_prefix' => 'fs_invoice_prefix',
            'invoice_due_days' => 'fs_invoice_due_days',
            'invoice_tax_rate' => 'fs_invoice_tax_rate',
            'invoice_tax_label' => 'fs_invoice_tax_label',
            'invoice_footer_text' => 'fs_invoice_footer_text',
            'invoice_payment_instructions' => 'fs_payment_instructions',
            'payment_instructions' => 'fs_payment_instructions',
            'payment_link' => 'fs_payment_url',
            'payment_url' => 'fs_payment_url',
            // Document Settings
            'proposal_expiry_days' => 'fs_proposal_expiry_days',
            'contract_expiry_days' => 'fs_contract_expiry_days',
            // Time Tracking
            'default_hourly_rate' => 'fs_default_hourly_rate',
            'time_rounding' => 'fs_time_rounding',
            'week_start' => 'fs_week_start',
            // Tax Settings
            'tax_entity_type' => 'fs_tax_entity_type',
            'tax_filing_status' => 'fs_tax_filing_status',
            'tax_state' => 'fs_tax_state',
            'tax_estimate_federal_rate' => 'fs_tax_estimate_federal_rate',
            'tax_estimate_state_rate' => 'fs_tax_estimate_state_rate',
            'tax_estimate_self_employment_rate' => 'fs_tax_estimate_self_employment_rate',
            // Internal Project Tracking
            'internal_tracking_enabled' => 'fs_internal_tracking_enabled',
            // Email Notification Preferences
            'notify_proposal_viewed' => 'fs_notify_proposal_viewed',
            'notify_contract_viewed' => 'fs_notify_contract_viewed',
            'notify_invoice_viewed' => 'fs_notify_invoice_viewed',
        );
        
        // Check if field is allowed
        if (!isset($field_map[$field])) {
            wp_send_json_error(array('message' => __('Invalid field', 'freelance-studio')));
        }
        
        $option_name = $field_map[$field];
        
        // Sanitize value based on field type
        switch ($field) {
            case 'business_email':
                $value = sanitize_email($value);
                break;
            case 'business_website':
            case 'booking_url':
            case 'payment_link':
            case 'payment_url':
                $value = esc_url_raw($value);
                break;
            case 'business_address':
            case 'invoice_footer_text':
                $value = sanitize_textarea_field($value);
                break;
            case 'invoice_payment_instructions':
            case 'payment_instructions':
                $value = wp_kses_post($value);
                break;
            case 'invoice_due_days':
            case 'proposal_expiry_days':
            case 'contract_expiry_days':
                $value = intval($value);
                break;
            case 'invoice_tax_rate':
            case 'default_hourly_rate':
            case 'tax_estimate_federal_rate':
            case 'tax_estimate_state_rate':
            case 'tax_estimate_self_employment_rate':
                $value = floatval($value);
                break;
            case 'internal_tracking_enabled':
            case 'notify_proposal_viewed':
            case 'notify_contract_viewed':
            case 'notify_invoice_viewed':
                $value = ($value === '1' || $value === 'true' || $value === true) ? '1' : '0';
                break;
            case 'my_role':
                $valid_roles = array('freelancer', 'designer', 'developer', 'project_manager', 'consultant', 'agency');
                if (!in_array($value, $valid_roles)) {
                    $value = 'freelancer';
                }
                break;
            default:
                $value = sanitize_text_field($value);
        }
        
        // Special handling for internal tracking - create internal client when first enabled
        if ($field === 'internal_tracking_enabled' && $value === '1') {
            $was_enabled = get_option('fs_internal_tracking_enabled', '0');
            if ($was_enabled !== '1') {
                $internal_client_id = FS_Client::create_internal_client();
                if ($internal_client_id) {
                    error_log('Freelance Studio: Created internal client with ID ' . $internal_client_id);
                }
            }
        }
        
        // Save the option
        $result = update_option($option_name, $value);
        
        wp_send_json_success(array(
            'message' => __('Saved', 'freelance-studio'),
            'field' => $field,
            'value' => $value
        ));
    }

    /**
     * Get all preset services
     */
    public function get_preset_services() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $services = FS_Preset_Service::get_all();
        
        wp_send_json_success(array('services' => $services));
    }

    /**
     * Save a preset service
     */
    public function save_preset_service() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        $data = array(
            'name' => sanitize_text_field(wp_unslash($_POST['name'] ?? '')),
            'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')),
            'pricing_type' => sanitize_text_field(wp_unslash($_POST['pricing_type'] ?? 'fixed')),
            'default_rate' => floatval($_POST['default_rate'] ?? 0),
            'default_quantity' => floatval($_POST['default_quantity'] ?? 1),
            'sort_order' => intval($_POST['sort_order'] ?? 0),
        );
        
        if (empty($data['name'])) {
            wp_send_json_error(array('message' => __('Service name is required', 'freelance-studio')));
        }
        
        if ($id) {
            $result = FS_Preset_Service::update($id, $data);
            $message = __('Service updated successfully', 'freelance-studio');
        } else {
            $id = FS_Preset_Service::create($data);
            $result = $id !== false;
            $message = __('Service created successfully', 'freelance-studio');
        }
        
        if ($result === false) {
            wp_send_json_error(array('message' => __('Failed to save service', 'freelance-studio')));
        }
        
        wp_send_json_success(array(
            'message' => $message,
            'id' => $id,
            'service' => FS_Preset_Service::get($id)
        ));
    }

    /**
     * Delete a preset service
     */
    public function delete_preset_service() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid service ID', 'freelance-studio')));
        }
        
        $result = FS_Preset_Service::delete($id);
        
        if ($result === false) {
            wp_send_json_error(array('message' => __('Failed to delete service', 'freelance-studio')));
        }
        
        wp_send_json_success(array('message' => __('Service deleted successfully', 'freelance-studio')));
    }
    
    /**
     * Get mini calendar HTML for dashboard widget
     */
    public function get_mini_calendar() {
        $this->verify_nonce();
        
        global $wpdb;
        
        $year = isset($_POST['year']) ? intval($_POST['year']) : gmdate('Y');
        $month = isset($_POST['month']) ? intval($_POST['month']) : gmdate('n');
        
        // Get events for this month
        $start_date = sprintf('%04d-%02d-01', $year, $month);
        $end_date = gmdate('Y-m-t', strtotime($start_date));
        
        $events = array();
        
        // Get Projects (start and end dates)
        $projects_table = $wpdb->prefix . 'fs_projects';
        $projects = $wpdb->get_results($wpdb->prepare(
            "SELECT id, name, start_date, due_date
             FROM {$projects_table}
             WHERE (start_date BETWEEN %s AND %s)
                OR (due_date BETWEEN %s AND %s)",
            $start_date, $end_date, $start_date, $end_date
        ));
        
        foreach ($projects as $project) {
            if ($project->start_date && $project->start_date >= $start_date && $project->start_date <= $end_date) {
                $events[] = array('date' => $project->start_date, 'type' => 'project');
            }
            if ($project->due_date && $project->due_date >= $start_date && $project->due_date <= $end_date) {
                $events[] = array('date' => $project->due_date, 'type' => 'project');
            }
        }
        
        // Get Invoices (due dates)
        $invoices_table = $wpdb->prefix . 'fs_invoices';
        $invoices = $wpdb->get_results($wpdb->prepare(
            "SELECT id, due_date FROM {$invoices_table}
             WHERE due_date BETWEEN %s AND %s AND status NOT IN ('paid', 'cancelled')",
            $start_date, $end_date
        ));
        
        foreach ($invoices as $invoice) {
            if ($invoice->due_date) {
                $events[] = array('date' => $invoice->due_date, 'type' => 'invoice');
            }
        }
        
        // Get Milestones
        $milestones_table = $wpdb->prefix . 'fs_milestones';
        $milestones = $wpdb->get_results($wpdb->prepare(
            "SELECT id, due_date FROM {$milestones_table}
             WHERE due_date BETWEEN %s AND %s AND status != 'completed'",
            $start_date, $end_date
        ));
        
        foreach ($milestones as $milestone) {
            if ($milestone->due_date) {
                $events[] = array('date' => $milestone->due_date, 'type' => 'milestone');
            }
        }
        
        // Get Tasks with due dates
        $tasks_table = $wpdb->prefix . 'fs_tasks';
        $tasks = $wpdb->get_results($wpdb->prepare(
            "SELECT id, due_date FROM {$tasks_table}
             WHERE due_date BETWEEN %s AND %s AND status != 'completed'",
            $start_date, $end_date
        ));
        
        foreach ($tasks as $task) {
            if ($task->due_date) {
                $events[] = array('date' => $task->due_date, 'type' => 'task');
            }
        }
        
        // Group events by date
        $events_by_date = array();
        foreach ($events as $event) {
            $date = $event['date'];
            if (!isset($events_by_date[$date])) {
                $events_by_date[$date] = array();
            }
            $events_by_date[$date][] = $event;
        }
        
        // Calculate calendar grid
        $first_day_of_month = mktime(0, 0, 0, $month, 1, $year);
        $days_in_month = gmdate('t', $first_day_of_month);
        $day_of_week = gmdate('N', $first_day_of_month);
        $month_name = gmdate('F', $first_day_of_month);
        $today = gmdate('Y-m-d');
        
        // Build HTML using same classes as calendar page sidebar
        ob_start();
        ?>
        <div class="fs-sidebar-mini-cal fs-dashboard-mini-cal" id="fs-dashboard-mini-calendar" 
             data-year="<?php echo esc_attr($year); ?>" 
             data-month="<?php echo esc_attr($month); ?>">
            <div class="fs-sidebar-mini-cal-header">
                <span class="fs-sidebar-mini-cal-title"><?php echo esc_html($month_name . ', ' . $year); ?></span>
                <div class="fs-sidebar-mini-cal-nav">
                    <button type="button" class="fs-mini-nav-btn fs-dashboard-cal-nav" data-direction="prev" title="<?php esc_attr_e('Previous month', 'freelance-studio'); ?>">
                        <span class="dashicons dashicons-arrow-left-alt2"></span>
                    </button>
                    <button type="button" class="fs-mini-nav-btn fs-dashboard-cal-nav" data-direction="next" title="<?php esc_attr_e('Next month', 'freelance-studio'); ?>">
                        <span class="dashicons dashicons-arrow-right-alt2"></span>
                    </button>
                </div>
            </div>
            <div class="fs-sidebar-mini-cal-grid">
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('M', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('T', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('W', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('T', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('F', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('S', 'freelance-studio'); ?></span>
                <span class="fs-sidebar-mini-cal-weekday"><?php esc_html_e('S', 'freelance-studio'); ?></span>
                <?php
                // Empty cells before first day
                $start_offset = $day_of_week - 1;
                for ($i = 0; $i < $start_offset; $i++): ?>
                    <span class="fs-sidebar-mini-cal-day fs-other-month"></span>
                <?php endfor;
                
                // Days of month
                for ($day = 1; $day <= $days_in_month; $day++):
                    $date_str = sprintf('%04d-%02d-%02d', $year, $month, $day);
                    $is_today = $date_str === $today;
                    $has_events = isset($events_by_date[$date_str]);
                    $event_count = $has_events ? count($events_by_date[$date_str]) : 0;
                    
                    $classes = array('fs-sidebar-mini-cal-day');
                    if ($is_today) $classes[] = 'fs-today';
                    if ($has_events) $classes[] = 'fs-has-events';
                ?>
                    <a href="<?php echo  esc_url( admin_url('admin.php?page=fs-calendar&year=' . $year . '&month=' . $month) ); ?>" 
                       class="<?php echo  esc_attr( implode(' ', $classes) ); ?>"
                       title="<?php echo  esc_attr( $has_events ? $event_count . ' event(s)' : '' ); ?>">
                        <?php echo  esc_html( $day ); ?>
                        <?php if ($has_events): ?>
                            <span class="fs-mini-cal-dot"></span>
                        <?php endif; ?>
                    </a>
                <?php endfor;
                
                // Empty cells after last day
                $remaining = (7 - (($start_offset + $days_in_month) % 7)) % 7;
                for ($i = 0; $i < $remaining; $i++): ?>
                    <span class="fs-sidebar-mini-cal-day fs-other-month"></span>
                <?php endfor; ?>
            </div>
        </div>
        <?php
        $html = ob_get_clean();
        
        wp_send_json_success(array('html' => $html));
    }

    /**
     * Get invoice wizard data (v3.2.0)
     * Returns projects, unbilled time, and services for the wizard
     */
    public function get_invoice_wizard_data() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        $projects = array();
        
        if ($project_id) {
            // Single project mode - get the specific project regardless of status
            $project = FS_Project::get($project_id);
            if ($project) {
                $client_id = $project->client_id;
                $projects[] = $project;
            }
        } elseif ($client_id) {
            // Get all active projects for client (running or upcoming)
            $all_projects = FS_Project::get_all(array(
                'client_id' => $client_id, 
                'status' => array('running', 'upcoming', 'active')
            ));
            $projects = $all_projects;
        } else {
            // No filter - get all active projects (running or upcoming)
            $all_projects = FS_Project::get_all(array(
                'status' => array('running', 'upcoming', 'active')
            ));
            $projects = $all_projects;
        }
        
        $data = array(
            'client_id' => $client_id,
            'projects' => array(),
        );
        
        foreach ($projects as $project) {
            $time_summary = FS_Invoice::get_unbilled_time_summary($project->id);
            $services = FS_Invoice::get_unbilled_services($project->id);
            
            // Get client name for display
            $client = FS_Client::get($project->client_id);
            $client_name = $client ? ($client->company_name ?: $client->name) : '';
            
            $data['projects'][] = array(
                'id' => $project->id,
                'name' => $project->name,
                'client_id' => $project->client_id,
                'client_name' => $client_name,
                'unbilled_hours' => round($time_summary['hours'], 2),
                'unbilled_services' => count($services),
                'unbilled_time' => $time_summary,
                'services' => $services,
                'has_unbilled_time' => $time_summary['count'] > 0,
                'has_services' => !empty($services),
            );
        }
        
        wp_send_json_success($data);
    }

    /**
     * Get unbilled time entries for a project (v3.2.0)
     */
    public function get_unbilled_time_entries() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Project ID required', 'freelance-studio')));
        }
        
        $entries = FS_Invoice::get_unbilled_time_entries($project_id);
        
        // Get project for default rate, fall back to global default
        $project = FS_Project::get($project_id);
        $project_rate = $project && isset($project->hourly_rate) ? floatval($project->hourly_rate) : 0;
        $global_default_rate = floatval(get_option('fs_default_hourly_rate', 75));
        
        // Format entries for display
        $formatted = array();
        foreach ($entries as $entry) {
            $hours = FS_Time_Entry::get_rounded_hours($entry->duration);
            // Use entry hourly_rate if set, otherwise project rate, otherwise global default
            $rate = !empty($entry->hourly_rate) ? floatval($entry->hourly_rate) : ($project_rate > 0 ? $project_rate : $global_default_rate);
            
            // Extract date from start_time
            $entry_date = $entry->start_time ? gmdate('Y-m-d', strtotime($entry->start_time)) : '';
            
            // Resolve parent task for recurring task grouping
            $task_name = '';
            $group_task_id = $entry->task_id;
            if ($entry->task_id) {
                $task = FS_Task::get($entry->task_id);
                if ($task) {
                    // If this is a child of a recurring task, use the parent for grouping
                    if ($task->parent_task_id) {
                        $parent_task = FS_Task::get($task->parent_task_id);
                        $task_name = $parent_task ? $parent_task->title : $task->title;
                        $group_task_id = $task->parent_task_id;
                    } else {
                        $task_name = $task->title;
                    }
                }
            }
            
            $formatted[] = array(
                'id' => $entry->id,
                'date' => $entry_date,
                'date_formatted' => $entry_date ? gmdate('M j, Y', strtotime($entry_date)) : '',
                'activity' => $entry->activity,
                'description' => $entry->description,
                'task_id' => $entry->task_id,
                'group_task_id' => $group_task_id,
                'task_name' => $task_name,
                'hours' => round($hours, 2),
                'rate' => $rate,
                'amount' => round($hours * $rate, 2),
                'duration_formatted' => sprintf('%d:%02d', floor($hours), ($hours - floor($hours)) * 60),
            );
        }
        
        wp_send_json_success(array('entries' => $formatted));
    }
    
    /**
     * Helper to get task name
     */
    private function get_task_name($task_id) {
        $task = FS_Task::get($task_id);
        return $task ? $task->title : '';
    }

    /**
     * Preview invoice items before creation (v3.2.0)
     */
    public function preview_invoice_items() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $invoice_type = isset($_POST['invoice_type']) ? sanitize_text_field(wp_unslash($_POST['invoice_type'])) : '';
        $project_data = isset($_POST['projects']) ? $_POST['projects'] : array();
        
        $all_items = array();
        $project_items = array();
        $grand_total = 0;
        
        foreach ($project_data as $proj) {
            $project_id = intval($proj['id']);
            $project = FS_Project::get($project_id);
            if (!$project) continue;
            
            $items = array();
            $project_subtotal = 0;
            
            // Process time entries
            if (in_array($invoice_type, array('time_entries', 'combined')) && !empty($proj['time_entry_ids'])) {
                $entry_ids = array_map('intval', $proj['time_entry_ids']);
                $grouping = isset($proj['time_grouping']) ? sanitize_text_field($proj['time_grouping']) : 'activity';
                $show_dates = isset($proj['show_dates']) && $proj['show_dates'] === 'true';
                
                // New options for v3.6.22
                $time_options = array(
                    'show_descriptions' => isset($proj['show_descriptions']) && $proj['show_descriptions'] === 'true',
                    'show_date_activity' => isset($proj['show_date_activity']) && $proj['show_date_activity'] === 'true',
                    'show_date_task' => isset($proj['show_date_task']) && $proj['show_date_task'] === 'true',
                    'show_date_descriptions' => isset($proj['show_date_descriptions']) && $proj['show_date_descriptions'] === 'true',
                );
                
                $time_items = FS_Invoice::build_time_entry_items($project_id, $entry_ids, $grouping, $show_dates, $time_options);
                foreach ($time_items as $item) {
                    $items[] = $item;
                    $project_subtotal += floatval($item['amount']);
                }
            }
            
            // Process services
            if (in_array($invoice_type, array('project_services', 'combined')) && !empty($proj['service_indices'])) {
                $service_indices = array_map('intval', $proj['service_indices']);
                $billing_option = isset($proj['billing_option']) ? sanitize_text_field($proj['billing_option']) : 'full';
                $percentage = isset($proj['percentage']) ? floatval($proj['percentage']) : 100;
                $custom_amount = isset($proj['custom_amount']) ? floatval($proj['custom_amount']) : 0;
                $milestone_label = isset($proj['milestone_label']) ? sanitize_text_field($proj['milestone_label']) : '';
                
                $service_items = FS_Invoice::build_service_items($project_id, $service_indices, $billing_option, $percentage, $custom_amount, $milestone_label);
                foreach ($service_items as $item) {
                    $items[] = $item;
                    $project_subtotal += floatval($item['amount']);
                }
            }
            
            if (!empty($items)) {
                $project_items[] = array(
                    'project_id' => $project_id,
                    'project_name' => $project->name,
                    'items' => $items,
                    'subtotal' => $project_subtotal,
                );
                $all_items = array_merge($all_items, $items);
                $grand_total += $project_subtotal;
            }
        }
        
        wp_send_json_success(array(
            'project_items' => $project_items,
            'all_items' => $all_items,
            'grand_total' => $grand_total,
        ));
    }

    /**
     * Create invoice from wizard (v3.2.0)
     */
    public function create_invoice_from_wizard() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $invoice_type = isset($_POST['invoice_type']) ? sanitize_text_field(wp_unslash($_POST['invoice_type'])) : '';
        $project_data = isset($_POST['projects']) ? $_POST['projects'] : array();
        $due_date = isset($_POST['due_date']) ? sanitize_text_field(wp_unslash($_POST['due_date'])) : '';
        $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';
        
        if (empty($project_data)) {
            wp_send_json_error(array('message' => __('No projects selected', 'freelance-studio')));
        }
        
        $all_items = array();
        $project_ids = array();
        $client_id = null;
        $time_grouping = null;
        $show_entry_dates = 0;
        
        foreach ($project_data as $proj) {
            $project_id = intval($proj['id']);
            $project = FS_Project::get($project_id);
            if (!$project) continue;
            
            $project_ids[] = $project_id;
            if (!$client_id) {
                $client_id = $project->client_id;
            }
            
            // Process time entries
            if (in_array($invoice_type, array('time_entries', 'combined')) && !empty($proj['time_entry_ids'])) {
                $entry_ids = array_map('intval', $proj['time_entry_ids']);
                $grouping = isset($proj['time_grouping']) ? sanitize_text_field($proj['time_grouping']) : 'activity';
                $show_dates = isset($proj['show_dates']) && $proj['show_dates'] === 'true';
                
                // New options for v3.6.22
                $time_options = array(
                    'show_descriptions' => isset($proj['show_descriptions']) && $proj['show_descriptions'] === 'true',
                    'show_date_activity' => isset($proj['show_date_activity']) && $proj['show_date_activity'] === 'true',
                    'show_date_task' => isset($proj['show_date_task']) && $proj['show_date_task'] === 'true',
                    'show_date_descriptions' => isset($proj['show_date_descriptions']) && $proj['show_date_descriptions'] === 'true',
                );
                
                $time_grouping = $grouping;
                $show_entry_dates = $show_dates ? 1 : 0;
                
                $time_items = FS_Invoice::build_time_entry_items($project_id, $entry_ids, $grouping, $show_dates, $time_options);
                $all_items = array_merge($all_items, $time_items);
            }
            
            // Process services
            if (in_array($invoice_type, array('project_services', 'combined')) && !empty($proj['service_indices'])) {
                $service_indices = array_map('intval', $proj['service_indices']);
                $billing_option = isset($proj['billing_option']) ? sanitize_text_field($proj['billing_option']) : 'full';
                $percentage = isset($proj['percentage']) ? floatval($proj['percentage']) : 100;
                $custom_amount = isset($proj['custom_amount']) ? floatval($proj['custom_amount']) : 0;
                $milestone_label = isset($proj['milestone_label']) ? sanitize_text_field($proj['milestone_label']) : '';
                
                $service_items = FS_Invoice::build_service_items($project_id, $service_indices, $billing_option, $percentage, $custom_amount, $milestone_label);
                $all_items = array_merge($all_items, $service_items);
            }
        }
        
        if (empty($all_items)) {
            wp_send_json_error(array('message' => __('No items to invoice', 'freelance-studio')));
        }
        
        // Determine invoice source
        $invoice_source = FS_Invoice::SOURCE_CUSTOM;
        if ($invoice_type === 'time_entries') {
            $invoice_source = FS_Invoice::SOURCE_TIME_ENTRIES;
        } elseif ($invoice_type === 'project_services') {
            $invoice_source = FS_Invoice::SOURCE_PROJECT_SERVICES;
        } elseif ($invoice_type === 'combined') {
            $invoice_source = FS_Invoice::SOURCE_COMBINED;
        }
        
        $invoice_id = FS_Invoice::create_from_wizard(array(
            'client_id' => $client_id,
            'project_ids' => $project_ids,
            'items' => $all_items,
            'invoice_source' => $invoice_source,
            'time_grouping' => $time_grouping,
            'show_entry_dates' => $show_entry_dates,
            'due_date' => $due_date,
            'notes' => $notes,
        ));
        
        if (!$invoice_id) {
            wp_send_json_error(array('message' => __('Failed to create invoice', 'freelance-studio')));
        }
        
        wp_send_json_success(array(
            'invoice_id' => $invoice_id,
            'redirect_url' => admin_url('admin.php?page=fs-finances&tab=invoices&action=view&id=' . $invoice_id),
            'message' => __('Invoice created successfully', 'freelance-studio'),
        ));
    }

    /**
     * Check if invoice is editable (v3.2.0)
     */
    public function check_invoice_editable() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
        
        if (!$invoice_id) {
            wp_send_json_error(array('message' => __('Invoice ID required', 'freelance-studio')));
        }
        
        $result = FS_Invoice::can_edit($invoice_id);
        wp_send_json_success($result);
    }

    /**
     * Fix orphaned time entries (marked invoiced but invoice deleted)
     * v3.2.16
     */
    public function fix_orphaned_time_entries() {
        $this->verify_nonce();
        $this->verify_capability();
        
        global $wpdb;
        $time_table = $wpdb->prefix . 'fs_time_entries';
        $invoice_table = $wpdb->prefix . 'fs_invoices';
        
        // Find time entries that are marked invoiced=1 but whose invoice_id doesn't exist
        $orphaned = $wpdb->get_results(
            "SELECT t.id, t.invoice_id 
             FROM {$time_table} t 
             LEFT JOIN {$invoice_table} i ON t.invoice_id = i.id 
             WHERE t.invoiced = 1 
             AND (t.invoice_id IS NULL OR i.id IS NULL)"
        );
        
        $count = count($orphaned);
        
        if ($count > 0) {
            // Reset these entries
            $wpdb->query(
                "UPDATE {$time_table} t 
                 LEFT JOIN {$invoice_table} i ON t.invoice_id = i.id 
                 SET t.invoiced = 0, t.invoice_id = NULL 
                 WHERE t.invoiced = 1 
                 AND (t.invoice_id IS NULL OR i.id IS NULL)"
            );
        }
        
        wp_send_json_success(array(
            'fixed' => $count,
            /* translators: 1. count/number */
            'message' => sprintf(__('%d orphaned time entries have been reset to billable status.', 'freelance-studio'), $count)
        ));
    }

    /**
     * Update quarterly tax payment
     * v3.4.21
     */
    public function update_quarterly_payment() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $record_id = isset($_POST['record_id']) ? intval($_POST['record_id']) : 0;
        $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
        
        if (!$record_id) {
            wp_send_json_error(array('message' => __('Invalid record ID', 'freelance-studio')));
        }
        
        // Verify the record belongs to current user
        $record = FS_Quarterly_Tax::get($record_id);
        if (!$record || $record->user_id != get_current_user_id()) {
            wp_send_json_error(array('message' => __('Record not found', 'freelance-studio')));
        }
        
        $result = FS_Quarterly_Tax::update_payment($record_id, $amount);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Payment updated successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to update payment', 'freelance-studio')));
        }
    }

    /**
     * Duplicate a project with all its tasks and milestones
     * v3.8.0
     */
    public function duplicate_project() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        // Create new project data
        $new_project_data = array(
            'client_id' => $project->client_id,
            'project_type_id' => $project->project_type_id,
            'name' => $project->name . ' ' . __('(Copy)', 'freelance-studio'),
            'description' => $project->description,
            'status' => FS_Project::STATUS_PENDING,
            'billing_type' => $project->billing_type,
            'billing_frequency' => $project->billing_frequency,
            'estimated_hours' => $project->estimated_hours,
            'fixed_price' => $project->fixed_price,
            'budget' => $project->budget,
            'services' => $project->services ? json_decode($project->services, true) : null,
            'deposit_type' => $project->deposit_type,
            'deposit_percentage' => $project->deposit_percentage,
            'deposit_amount' => $project->deposit_amount,
            'deposit_paid' => 0,
            'is_retainer' => $project->is_retainer,
            'retainer_amount' => $project->retainer_amount,
            'retainer_hours' => $project->retainer_hours,
            'notes' => $project->notes,
        );
        
        $new_project_id = FS_Project::create($new_project_data);
        
        if (!$new_project_id) {
            wp_send_json_error(array('message' => __('Failed to duplicate project', 'freelance-studio')));
        }
        
        // Duplicate milestones
        $milestones = FS_Milestone::get_by_project($project_id);
        $milestone_map = array(); // Old ID => New ID mapping
        
        foreach ($milestones as $milestone) {
            $new_milestone_data = array(
                'project_id' => $new_project_id,
                'name' => $milestone->name,
                'description' => $milestone->description,
                'status' => FS_Milestone::STATUS_PENDING,
                'position' => $milestone->position,
                'billing_percentage' => $milestone->billing_percentage,
                'billing_amount' => $milestone->billing_amount,
            );
            
            $new_milestone_id = FS_Milestone::create($new_milestone_data);
            if ($new_milestone_id) {
                $milestone_map[$milestone->id] = $new_milestone_id;
            }
        }
        
        // Duplicate tasks
        $tasks = FS_Task::get_by_project($project_id);
        
        foreach ($tasks as $task) {
            $new_task_data = array(
                'project_id' => $new_project_id,
                'milestone_id' => isset($milestone_map[$task->milestone_id]) ? $milestone_map[$task->milestone_id] : null,
                'name' => $task->name,
                'description' => $task->description,
                'status' => FS_Task::STATUS_PENDING,
                'priority' => $task->priority,
                'assigned_to' => $task->assigned_to,
                'estimated_hours' => $task->estimated_hours,
                'position' => $task->position,
            );
            
            FS_Task::create($new_task_data);
        }
        
        wp_send_json_success(array(
            'message' => __('Project duplicated successfully', 'freelance-studio'),
            'project_id' => $new_project_id,
            'redirect_url' => admin_url('admin.php?page=fs-projects&action=view&id=' . $new_project_id)
        ));
    }

    /**
     * Batch mark invoices as paid
     * v3.8.0
     */
    public function batch_mark_invoices_paid() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $invoice_ids = isset($_POST['invoice_ids']) ? array_map('intval', $_POST['invoice_ids']) : array();
        
        if (empty($invoice_ids)) {
            wp_send_json_error(array('message' => __('No invoices selected', 'freelance-studio')));
        }
        
        $paid_date = isset($_POST['paid_date']) ? sanitize_text_field(wp_unslash($_POST['paid_date'])) : gmdate('Y-m-d');
        $updated = 0;
        
        foreach ($invoice_ids as $invoice_id) {
            $invoice = FS_Invoice::get($invoice_id);
            if ($invoice && $invoice->status !== 'paid') {
                $result = FS_Invoice::update($invoice_id, array(
                    'status' => 'paid',
                    'paid_date' => $paid_date,
                    'amount_paid' => $invoice->total,
                    'balance_due' => 0
                ));
                
                if ($result) {
                    $updated++;
                }
            }
        }
        
        wp_send_json_success(array(
            /* translators: 1. count/number */
            'message' => sprintf(__('%d invoices marked as paid', 'freelance-studio'), $updated),
            'updated' => $updated
        ));
    }

    /**
     * Export data to CSV
     * v3.8.0
     */
    public function export_csv() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : '';
        $year = isset($_POST['year']) ? intval($_POST['year']) : gmdate('Y');
        
        $valid_types = array('clients', 'invoices', 'time_entries', 'expenses', '1099');
        
        if (!in_array($type, $valid_types)) {
            wp_send_json_error(array('message' => __('Invalid export type', 'freelance-studio')));
        }
        
        $data = array();
        $headers = array();
        $filename = '';
        
        switch ($type) {
            case 'clients':
                $headers = array('ID', 'Company Name', 'Contact Name', 'Email', 'Phone', 'Address', 'City', 'State', 'ZIP', 'Country', 'Created Date');
                $clients = FS_Client::get_all();
                foreach ($clients as $client) {
                    $data[] = array(
                        $client->id,
                        $client->company_name,
                        $client->name,
                        $client->email,
                        $client->phone,
                        $client->address,
                        $client->city,
                        $client->state,
                        $client->zip,
                        $client->country,
                        $client->created_at
                    );
                }
                $filename = 'clients-export-' . gmdate('Y-m-d') . '.csv';
                break;
                
            case 'invoices':
                $headers = array('Invoice #', 'Client', 'Project', 'Invoice Date', 'Due Date', 'Paid Date', 'Subtotal', 'Tax', 'Discount', 'Total', 'Amount Paid', 'Balance Due', 'Status');
                $invoices = FS_Invoice::get_all(array('year' => $year));
                foreach ($invoices as $inv) {
                    $data[] = array(
                        $inv->invoice_number,
                        $inv->client_company ?: $inv->client_name,
                        $inv->project_name ?: '',
                        $inv->invoice_date,
                        $inv->due_date,
                        $inv->paid_date ?: '',
                        $inv->subtotal,
                        $inv->tax_amount,
                        $inv->discount_amount,
                        $inv->total,
                        $inv->amount_paid,
                        $inv->balance_due,
                        $inv->status
                    );
                }
                $filename = 'invoices-' . $year . '-export-' . gmdate('Y-m-d') . '.csv';
                break;
                
            case 'time_entries':
                $headers = array('Date', 'Client', 'Project', 'Activity', 'Description', 'Start Time', 'End Time', 'Duration (hrs)', 'Hourly Rate', 'Amount', 'Invoiced');
                $start_date = $year . '-01-01';
                $end_date = $year . '-12-31';
                $entries = FS_Time_Entry::get_all(array(
                    'start_date' => $start_date,
                    'end_date' => $end_date,
                    'running' => false
                ));
                foreach ($entries as $entry) {
                    $hours = FS_Time_Entry::get_rounded_hours($entry->duration);
                    $data[] = array(
                        $entry->entry_date,
                        $entry->client_name ?: '',
                        $entry->project_name ?: '',
                        $entry->activity_name ?: '',
                        $entry->description,
                        $entry->start_time,
                        $entry->end_time,
                        $hours,
                        $entry->hourly_rate,
                        $entry->amount,
                        $entry->invoiced ? 'Yes' : 'No'
                    );
                }
                $filename = 'time-entries-' . $year . '-export-' . gmdate('Y-m-d') . '.csv';
                break;
                
            case 'expenses':
                $headers = array('Date', 'Vendor', 'Category', 'Description', 'Amount', 'Project', 'Client', 'Tax Deductible', 'Receipt');
                $expenses = FS_Expense::get_all(array(
                    'start_date' => $year . '-01-01',
                    'end_date' => $year . '-12-31'
                ));
                foreach ($expenses as $exp) {
                    $data[] = array(
                        $exp->expense_date,
                        $exp->vendor,
                        $exp->category,
                        $exp->description,
                        $exp->amount,
                        $exp->project_name ?: '',
                        $exp->client_name ?: '',
                        $exp->is_deductible ? 'Yes' : 'No',
                        $exp->receipt_url ? 'Yes' : 'No'
                    );
                }
                $filename = 'expenses-' . $year . '-export-' . gmdate('Y-m-d') . '.csv';
                break;
                
            case '1099':
            case 'income_summary':
                $headers = array('Client Company', 'Contact Name', 'Address', 'City', 'State', 'ZIP', 'Total Received');
                $clients_income = $this->get_income_summary_data($year);
                foreach ($clients_income as $client) {
                    $data[] = array(
                        $client['company_name'],
                        $client['name'],
                        $client['address'],
                        $client['city'],
                        $client['state'],
                        $client['zip'],
                        $client['total_received']
                    );
                }
                $filename = 'income-summary-' . $year . '.csv';
                break;
        }
        
        // Generate CSV content
        $csv_content = $this->generate_csv($headers, $data);
        
        wp_send_json_success(array(
            'csv' => $csv_content,
            'filename' => $filename
        ));
    }

    /**
     * Get income summary data for a year
     * Shows how much each client paid the freelancer
     * v3.8.0, updated v3.8.1
     */
    private function get_income_summary_data($year) {
        global $wpdb;
        
        $invoices_table = $wpdb->prefix . 'fs_invoices';
        $clients_table = $wpdb->prefix . 'fs_clients';
        
        $results = $wpdb->get_results($wpdb->prepare(
            "SELECT 
                c.id,
                c.company_name,
                c.name,
                c.address,
                c.city,
                c.state,
                c.zip,
                SUM(i.amount_paid) as total_received
             FROM {$invoices_table} i
             JOIN {$clients_table} c ON i.client_id = c.id
             WHERE YEAR(i.paid_at) = %d 
             AND i.status = 'paid'
             AND i.amount_paid > 0
             GROUP BY c.id
             ORDER BY total_received DESC",
            $year
        ), ARRAY_A);
        
        return $results ?: array();
    }

    /**
     * Generate CSV string from headers and data
     * v3.8.0
     */
    private function generate_csv($headers, $data) {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Using PHP stream wrapper for in-memory CSV generation.
        $output = fopen('php://temp', 'r+');
        
        // Add UTF-8 BOM for Excel compatibility
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writing BOM marker to stream for Excel compatibility.
        fwrite($output, "\xEF\xBB\xBF");
        
        // Write headers
        fputcsv($output, $headers);
        
        // Write data rows
        foreach ($data as $row) {
            fputcsv($output, $row);
        }
        
        rewind($output);
        $csv = stream_get_contents($output);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing stream/file handle after CSV generation.
        fclose($output);
        
        return $csv;
    }

    /**
     * Generate Income Summary PDF Report
     * v3.8.0, updated v3.8.1
     */
    public function generate_1099_pdf() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $year = isset($_POST['year']) ? intval($_POST['year']) : gmdate('Y');
        
        // Get income summary data (all clients)
        $clients_income = $this->get_income_summary_data($year);
        
        if (empty($clients_income)) {
            wp_send_json_error(array('message' => __('No payments received from clients found for this year.', 'freelance-studio')));
        }
        
        // Get business info
        $settings = get_option('fs_settings', array());
        
        // Generate PDF using PDF Service
        $pdf_service = new FS_PDF_Service();
        $pdf_url = $pdf_service->generate_income_summary_report($year, $clients_income, $settings);
        
        if ($pdf_url) {
            wp_send_json_success(array(
                'pdf_url' => $pdf_url,
                'message' => __('Income Summary Report generated successfully', 'freelance-studio')
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to generate PDF', 'freelance-studio')));
        }
    }

    /**
     * Cancel booking (admin)
     */
    public function cancel_booking() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $booking_id = isset($_POST['booking_id']) ? intval($_POST['booking_id']) : 0;
        
        if (!$booking_id) {
            wp_send_json_error(array('message' => __('Invalid booking ID', 'freelance-studio')));
        }
        
        $booking = FS_Booking::get($booking_id);
        
        if (!$booking) {
            wp_send_json_error(array('message' => __('Booking not found', 'freelance-studio')));
        }
        
        // Check if already cancelled
        if ($booking->status === 'cancelled') {
            wp_send_json_error(array('message' => __('Booking is already cancelled', 'freelance-studio')));
        }
        
        // Cancel the booking
        $result = FS_Booking::cancel($booking_id, '', 'admin');
        
        if ($result !== false) {
            // Note: FS_Booking::cancel() already sends cancellation notification
            wp_send_json_success(array('message' => __('Booking cancelled successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to cancel booking', 'freelance-studio')));
        }
    }

    /**
     * Save proposal section as template
     */
    public function save_proposal_section_template() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
        $content = isset($_POST['content']) ? wp_kses_post(wp_unslash($_POST['content'])) : '';
        $position = isset($_POST['position']) ? sanitize_text_field($_POST['position']) : 'before_pricing';
        
        if (empty($title)) {
            wp_send_json_error(array('message' => __('Template title is required', 'freelance-studio')));
        }
        
        $result = FS_Proposal_Section::create(array(
            'title' => $title,
            'content' => $content,
            'position' => $position,
        ));
        
        if ($result) {
            wp_send_json_success(array(
                'message' => __('Template saved successfully', 'freelance-studio'),
                'template_id' => $result,
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to save template', 'freelance-studio')));
        }
    }

    /**
     * Delete proposal section template
     */
    public function delete_proposal_section_template() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(array('message' => __('Invalid template ID', 'freelance-studio')));
        }
        
        $result = FS_Proposal_Section::delete($id);
        
        if ($result) {
            wp_send_json_success(array('message' => __('Template deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete template', 'freelance-studio')));
        }
    }

    /**
     * Get all proposal section templates
     */
    public function get_proposal_section_templates() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $position = isset($_POST['position']) ? sanitize_text_field($_POST['position']) : null;
        
        $templates = FS_Proposal_Section::get_all($position);
        
        wp_send_json_success(array('templates' => $templates));
    }

    /**
     * Upload file to project
     */
    public function upload_project_file() {
        $this->verify_nonce();
        $this->verify_capability();
        
        // Project file uploads are a Pro feature
        if ( function_exists( 'fs_studio_is_free_plan' ) && fs_studio_is_free_plan() ) {
            wp_send_json_error( array( 'message' => __( 'Project file uploads are available with Freelance Studio Pro.', 'freelance-studio' ) ) );
        }
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $category = isset($_POST['category']) ? sanitize_text_field($_POST['category']) : 'general';
        $shared_with_client = isset($_POST['shared_with_client']) ? intval($_POST['shared_with_client']) : 0;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        // Check project exists
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        // Check for uploaded file
        if (empty($_FILES['file'])) {
            wp_send_json_error(array('message' => __('No file uploaded', 'freelance-studio')));
        }
        
        // Handle the upload using WordPress media functions
        require_once(ABSPATH . 'wp-admin/includes/file.php');
        require_once(ABSPATH . 'wp-admin/includes/image.php');
        require_once(ABSPATH . 'wp-admin/includes/media.php');
        
        $attachment_id = media_handle_upload('file', 0);
        
        if (is_wp_error($attachment_id)) {
            wp_send_json_error(array('message' => $attachment_id->get_error_message()));
        }
        
        // Create project file record
        $file_id = FS_Project_File::create(array(
            'project_id' => $project_id,
            'attachment_id' => $attachment_id,
            'category' => $category,
            'shared_with_client' => $shared_with_client,
            'uploaded_by' => get_current_user_id(),
        ));
        
        if (!$file_id) {
            // Clean up attachment if record creation failed
            wp_delete_attachment($attachment_id, true);
            wp_send_json_error(array('message' => __('Failed to save file record', 'freelance-studio')));
        }
        
        $file = FS_Project_File::get_with_details($file_id);
        
        wp_send_json_success(array(
            'message' => __('File uploaded successfully', 'freelance-studio'),
            'file' => $file,
        ));
    }

    /**
     * Delete project file
     */
    public function delete_project_file() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $file_id = isset($_POST['file_id']) ? intval($_POST['file_id']) : 0;
        
        if (!$file_id) {
            wp_send_json_error(array('message' => __('Invalid file ID', 'freelance-studio')));
        }
        
        $file = FS_Project_File::get($file_id);
        if (!$file) {
            wp_send_json_error(array('message' => __('File not found', 'freelance-studio')));
        }
        
        $result = FS_Project_File::delete($file_id, true);
        
        if ($result) {
            wp_send_json_success(array('message' => __('File deleted successfully', 'freelance-studio')));
        } else {
            wp_send_json_error(array('message' => __('Failed to delete file', 'freelance-studio')));
        }
    }

    /**
     * Update project file
     */
    public function update_project_file() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $file_id = isset($_POST['file_id']) ? intval($_POST['file_id']) : 0;
        
        if (!$file_id) {
            wp_send_json_error(array('message' => __('Invalid file ID', 'freelance-studio')));
        }
        
        $file = FS_Project_File::get($file_id);
        if (!$file) {
            wp_send_json_error(array('message' => __('File not found', 'freelance-studio')));
        }
        
        $data = array();
        
        if (isset($_POST['category'])) {
            $data['category'] = sanitize_text_field($_POST['category']);
        }
        
        if (isset($_POST['shared_with_client'])) {
            $data['shared_with_client'] = intval($_POST['shared_with_client']);
        }
        
        if (isset($_POST['notes'])) {
            $data['notes'] = sanitize_textarea_field($_POST['notes']);
        }
        
        if (empty($data)) {
            wp_send_json_error(array('message' => __('No data to update', 'freelance-studio')));
        }
        
        $result = FS_Project_File::update($file_id, $data);
        
        if ($result !== false) {
            $updated_file = FS_Project_File::get_with_details($file_id);
            wp_send_json_success(array(
                'message' => __('File updated successfully', 'freelance-studio'),
                'file' => $updated_file,
            ));
        } else {
            wp_send_json_error(array('message' => __('Failed to update file', 'freelance-studio')));
        }
    }

    /**
     * Get project files
     */
    public function get_project_files() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $category = isset($_POST['category']) ? sanitize_text_field($_POST['category']) : null;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $args = array();
        if ($category) {
            $args['category'] = $category;
        }
        
        $files = FS_Project_File::get_by_project($project_id, $args);
        
        // Enrich each file
        $enriched_files = array();
        foreach ($files as $file) {
            $enriched_files[] = FS_Project_File::enrich_file($file);
        }
        
        $stats = FS_Project_File::get_project_stats($project_id);
        
        wp_send_json_success(array(
            'files' => $enriched_files,
            'stats' => $stats,
        ));
    }

    /**
     * Generate client upload link
     */
    public function generate_upload_link() {
        $this->verify_nonce();
        $this->verify_capability();
        
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        $expires_days = isset($_POST['expires_days']) ? intval($_POST['expires_days']) : 7;
        
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project ID', 'freelance-studio')));
        }
        
        $project = FS_Project::get($project_id);
        if (!$project) {
            wp_send_json_error(array('message' => __('Project not found', 'freelance-studio')));
        }
        
        // Limit expiry days
        $expires_days = max(1, min(30, $expires_days));
        
        $token = FS_Project_File::generate_upload_token($project_id, $expires_days);
        
        $upload_url = home_url('/fs/upload/' . $token);
        
        wp_send_json_success(array(
            'message' => __('Upload link generated successfully', 'freelance-studio'),
            'url' => $upload_url,
            'token' => $token,
            'expires_days' => $expires_days,
        ));
    }

    /**
     * Client file upload via token (no auth required)
     */
    public function client_upload_file() {
        // This uses a token instead of nonce
        $token = isset($_POST['token']) ? sanitize_text_field($_POST['token']) : '';
        
        if (!$token) {
            wp_send_json_error(array('message' => __('Invalid upload token', 'freelance-studio')));
        }
        
        $token_record = FS_Project_File::validate_upload_token($token);
        
        if (!$token_record) {
            wp_send_json_error(array('message' => __('Upload link has expired or is invalid', 'freelance-studio')));
        }
        
        // Check for uploaded file
        if (empty($_FILES['file'])) {
            wp_send_json_error(array('message' => __('No file uploaded', 'freelance-studio')));
        }
        
        // Handle the upload
        require_once(ABSPATH . 'wp-admin/includes/file.php');
        require_once(ABSPATH . 'wp-admin/includes/image.php');
        require_once(ABSPATH . 'wp-admin/includes/media.php');
        
        $attachment_id = media_handle_upload('file', 0);
        
        if (is_wp_error($attachment_id)) {
            wp_send_json_error(array('message' => $attachment_id->get_error_message()));
        }
        
        // Create project file record
        $file_id = FS_Project_File::create(array(
            'project_id' => $token_record->project_id,
            'attachment_id' => $attachment_id,
            'category' => 'client_upload',
            'uploaded_by' => 0, // No user for client uploads
            'uploaded_by_client' => 1,
            'shared_with_client' => 1,
        ));
        
        if (!$file_id) {
            wp_delete_attachment($attachment_id, true);
            wp_send_json_error(array('message' => __('Failed to save file record', 'freelance-studio')));
        }
        
        $file = FS_Project_File::get_with_details($file_id);
        
        // Send notification email to admin
        $this->send_client_upload_notification($token_record->project_id, $file);
        
        wp_send_json_success(array(
            'message' => __('File uploaded successfully', 'freelance-studio'),
            'file' => $file,
        ));
    }

    /**
     * Send notification when client uploads a file
     */
    private function send_client_upload_notification($project_id, $file) {
        $project = FS_Project::get($project_id);
        if (!$project) {
            return;
        }
        
        $client = FS_Client::get($project->client_id);
        $client_name = $client ? ($client->company_name ?: $client->name) : __('Client', 'freelance-studio');
        
        // Get admin email
        $admin_email = get_option('fs_notification_email', get_option('admin_email'));
        
        $business_name = get_option('fs_business_name', get_bloginfo('name'));
        $brand_color = get_option('fs_brand_color', '#1FBBA6');
        
        /* translators: 1. project name, 2. uploader name */
        $subject = sprintf(__('[%1$s] New file uploaded by %2$s', 'freelance-studio'), $business_name, $client_name);
        
        // Build HTML email
        $message = $this->get_client_upload_notification_html($project, $client_name, $file, $brand_color);
        
        $headers = array(
            'Content-Type: text/html; charset=UTF-8',
            'From: ' . $business_name . ' <' . get_option('admin_email') . '>'
        );
        
        wp_mail($admin_email, $subject, $message, $headers);
    }
    
    /**
     * Get HTML for client upload notification email
     */
    private function get_client_upload_notification_html($project, $client_name, $file, $brand_color) {
        $project_url = admin_url('admin.php?page=fs-projects&action=view&id=' . $project->id . '&tab=files');
        $filename = $file->filename ?? __('Unknown file', 'freelance-studio');
        $filesize = $file->filesize_formatted ?? '';
        
        ob_start();
        ?>
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
        </head>
        <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f3f4f6;">
            <table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
                <tr>
                    <td align="center">
                        <table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
                            <tr>
                                <td style="padding: 40px;">
                                    <div style="background-color: <?php echo esc_attr($brand_color); ?>20; color: <?php echo esc_attr($brand_color); ?>; padding: 16px 24px; border-radius: 8px; margin-bottom: 24px; text-align: center;">
                                        <strong style="font-size: 18px;">📁 <?php esc_html_e('New File Uploaded', 'freelance-studio'); ?></strong>
                                    </div>
                                    
                                    <p style="margin: 0 0 16px 0; color: #374151; font-size: 16px;">
                                        <?php /* translators: 1. has uploaded value */ ?>
                                        <?php echo wp_kses_post( sprintf( __('%s has uploaded a file to your project.', 'freelance-studio'), '<strong>' . esc_html($client_name) . '</strong>')); ?>
                                    </p>
                                    
                                    <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f9fafb; border-radius: 8px; padding: 20px; margin-bottom: 24px;">
                                        <tr>
                                            <td>
                                                <p style="margin: 0 0 8px 0; color: #6b7280; font-size: 14px;"><?php esc_html_e('Project', 'freelance-studio'); ?></p>
                                                <p style="margin: 0 0 16px 0; color: #111827; font-size: 18px; font-weight: 600;"><?php echo esc_html($project->name); ?></p>
                                                
                                                <p style="margin: 0 0 8px 0; color: #6b7280; font-size: 14px;"><?php esc_html_e('File Name', 'freelance-studio'); ?></p>
                                                <p style="margin: 0 0 16px 0; color: #111827; font-size: 16px;">📄 <?php echo esc_html($filename); ?></p>
                                                
                                                <?php if ($filesize): ?>
                                                <p style="margin: 0 0 8px 0; color: #6b7280; font-size: 14px;"><?php esc_html_e('File Size', 'freelance-studio'); ?></p>
                                                <p style="margin: 0; color: #111827; font-size: 16px;"><?php echo esc_html($filesize); ?></p>
                                                <?php endif; ?>
                                            </td>
                                        </tr>
                                    </table>
                                    
                                    <table width="100%" cellpadding="0" cellspacing="0">
                                        <tr>
                                            <td align="center">
                                                <a href="<?php echo esc_url($project_url); ?>" style="display: inline-block; padding: 16px 32px; background-color: <?php echo esc_attr($brand_color); ?>; color: #ffffff; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600;">
                                                    <?php esc_html_e('View Project Files', 'freelance-studio'); ?>
                                                </a>
                                            </td>
                                        </tr>
                                    </table>
                                    
                                    <p style="margin: 24px 0 0 0; color: #9ca3af; font-size: 12px; text-align: center;">
                                        <?php esc_html_e('This notification was sent by Freelance Studio.', 'freelance-studio'); ?>
                                    </p>
                                </td>
                            </tr>
                        </table>
                    </td>
                </tr>
            </table>
        </body>
        </html>
        <?php
        return ob_get_clean();
    }

    /**
     * Portal file upload (client uploads from portal)
     */
    public function portal_upload_file() {
        // Validate portal token
        $portal_token = isset($_POST['portal_token']) ? sanitize_text_field($_POST['portal_token']) : '';
        $project_id = isset($_POST['project_id']) ? intval($_POST['project_id']) : 0;
        
        if (!$portal_token || !$project_id) {
            wp_send_json_error(array('message' => __('Invalid request', 'freelance-studio')));
        }
        
        // Validate portal token
        global $wpdb;
        $token_table = $wpdb->prefix . 'fs_portal_tokens';
        $valid_token = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $token_table WHERE token = %s AND project_id = %d AND expires_at > NOW()",
            $portal_token,
            $project_id
        ));
        
        if (!$valid_token) {
            wp_send_json_error(array('message' => __('Invalid or expired portal access', 'freelance-studio')));
        }
        
        // Check for uploaded file
        if (empty($_FILES['file'])) {
            wp_send_json_error(array('message' => __('No file uploaded', 'freelance-studio')));
        }
        
        // Handle the upload
        require_once(ABSPATH . 'wp-admin/includes/file.php');
        require_once(ABSPATH . 'wp-admin/includes/image.php');
        require_once(ABSPATH . 'wp-admin/includes/media.php');
        
        $attachment_id = media_handle_upload('file', 0);
        
        if (is_wp_error($attachment_id)) {
            wp_send_json_error(array('message' => $attachment_id->get_error_message()));
        }
        
        // Create project file record
        $file_id = FS_Project_File::create(array(
            'project_id' => $project_id,
            'attachment_id' => $attachment_id,
            'category' => 'client_upload',
            'uploaded_by_client' => 1,
            'shared_with_client' => 1,
        ));
        
        if (!$file_id) {
            wp_delete_attachment($attachment_id, true);
            wp_send_json_error(array('message' => __('Failed to save file record', 'freelance-studio')));
        }
        
        $file = FS_Project_File::get_with_details($file_id);
        
        wp_send_json_success(array(
            'message' => __('File uploaded successfully', 'freelance-studio'),
            'file' => $file,
        ));
    }

    /**
     * Export all Freelance Studio data
     */
    public function export_all_data() {
        $this->verify_nonce();
        $this->verify_capability('manage_options');
        
        global $wpdb;
        $prefix = $wpdb->prefix . 'fs_';
        
        // List of all tables to export
        $tables = array(
            'clients',
            'client_contacts',
            'projects',
            'tasks',
            'milestones',
            'time_entries',
            'invoices',
            'invoice_items',
            'invoice_projects',
            'expenses',
            'manual_income',
            'proposals',
            'contracts',
            'contract_sections',
            'bookings',
            'forms',
            'form_responses',
            'form_templates',
            'project_files',
            'upload_tokens',
            'annual_deductions',
            'quarterly_tax_payments',
            'retainer_periods',
            'proposal_section_templates',
        );
        
        $export = array(
            'meta' => array(
                'plugin_version' => JEEB_FS_VERSION,
                'export_date' => current_time('c'),
                'site_url' => get_site_url(),
                'site_name' => get_bloginfo('name'),
            ),
            'settings' => array(),
            'tables' => array(),
        );
        
        // Export settings
        $settings_keys = array(
            'fs_business_name',
            'fs_business_email',
            'fs_business_phone',
            'fs_business_website',
            'fs_business_address',
            'fs_business_city',
            'fs_business_state',
            'fs_business_zip',
            'fs_business_country',
            'fs_business_logo',
            'fs_business_logo_light',
            'fs_default_hourly_rate',
            'fs_currency',
            'fs_payment_terms',
            'fs_tax_rate',
            'fs_invoice_prefix',
            'fs_invoice_next_number',
            'fs_proposal_prefix',
            'fs_contract_prefix',
            'fs_invoice_due_days',
            'fs_invoice_footer_note',
            'fs_stripe_publishable_key',
            'fs_stripe_secret_key',
            'fs_paypal_email',
            'fs_paypal_client_id',
            'fs_bank_details',
            'fs_tidycal_url',
            'fs_calendly_url',
            'fs_designer_title',
            'fs_kanban_column_labels',
            'fs_booking_meeting_types',
            'fs_email_reminder_enabled',
            'fs_email_reminder_days',
        );
        
        foreach ($settings_keys as $key) {
            $value = get_option($key, null);
            if ($value !== null) {
                $export['settings'][$key] = $value;
            }
        }
        
        // Export each table
        foreach ($tables as $table_name) {
            $full_table = $prefix . $table_name;
            
            // Check if table exists
            $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$full_table}'");
            if (!$table_exists) {
                continue;
            }
            
            $rows = $wpdb->get_results("SELECT * FROM {$full_table}", ARRAY_A);
            $export['tables'][$table_name] = array(
                'count' => count($rows),
                'data' => $rows,
            );
        }
        
        // Generate filename
        $site_name = sanitize_file_name(get_bloginfo('name'));
        $date = gmdate('Y-m-d');
        $filename = "freelance-studio-export-{$site_name}-{$date}.json";
        
        wp_send_json_success(array(
            'filename' => $filename,
            'export' => $export,
        ));
    }
    
    /**
     * Export all data as CSV files in a ZIP archive
     */
    public function export_csv_zip() {
        $this->verify_nonce();
        $this->verify_capability('manage_options');
        
        global $wpdb;
        $prefix = $wpdb->prefix . 'fs_';
        
        // List of all tables to export
        $tables = array(
            'clients',
            'client_contacts',
            'projects',
            'tasks',
            'milestones',
            'time_entries',
            'invoices',
            'invoice_items',
            'invoice_projects',
            'expenses',
            'manual_income',
            'proposals',
            'contracts',
            'contract_sections',
            'bookings',
            'forms',
            'form_responses',
            'form_templates',
            'project_files',
            'upload_tokens',
            'annual_deductions',
            'quarterly_tax_payments',
            'retainer_periods',
            'proposal_section_templates',
        );
        
        // Create temp directory for CSV files
        $upload_dir = wp_upload_dir();
        $temp_dir = $upload_dir['basedir'] . '/fs-temp-export-' . uniqid();
        
        if (!wp_mkdir_p($temp_dir)) {
            wp_send_json_error(__('Could not create temporary directory', 'freelance-studio'));
        }
        
        $file_count = 0;
        $csv_files = array();
        
        // Export each table as CSV
        foreach ($tables as $table_name) {
            $full_table = $prefix . $table_name;
            
            // Check if table exists
            $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$full_table}'");
            if (!$table_exists) {
                continue;
            }
            
            $rows = $wpdb->get_results("SELECT * FROM {$full_table}", ARRAY_A);
            
            if (empty($rows)) {
                continue;
            }
            
            $csv_path = $temp_dir . '/' . $table_name . '.csv';
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Writing temporary CSV file for data export.
            $fp = fopen($csv_path, 'w');
            
            if ($fp === false) {
                continue;
            }
            
            // Add BOM for Excel compatibility
            fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
            
            // Write header row
            fputcsv($fp, array_keys($rows[0]));
            
            // Write data rows
            foreach ($rows as $row) {
                // Handle JSON fields - decode and re-encode for readability
                foreach ($row as $key => $value) {
                    if (is_string($value) && (strpos($value, '[') === 0 || strpos($value, '{') === 0)) {
                        $decoded = json_decode($value, true);
                        if (json_last_error() === JSON_ERROR_NONE) {
                            $row[$key] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
                        }
                    }
                }
                fputcsv($fp, $row);
            }
            
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing stream/file handle after CSV generation.
            fclose($fp);
            $csv_files[] = $csv_path;
            $file_count++;
        }
        
        // Also export settings as a CSV
        $settings_keys = array(
            'fs_business_name',
            'fs_business_email',
            'fs_business_phone',
            'fs_business_website',
            'fs_business_address',
            'fs_business_city',
            'fs_business_state',
            'fs_business_zip',
            'fs_business_country',
            'fs_business_logo',
            'fs_business_logo_light',
            'fs_default_hourly_rate',
            'fs_currency',
            'fs_payment_terms',
            'fs_tax_rate',
            'fs_invoice_prefix',
            'fs_invoice_next_number',
            'fs_proposal_prefix',
            'fs_contract_prefix',
            'fs_invoice_due_days',
            'fs_invoice_footer_note',
            'fs_tidycal_url',
            'fs_calendly_url',
            'fs_designer_title',
        );
        
        $settings_csv_path = $temp_dir . '/_settings.csv';
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Writing temporary CSV file for data export.
        $fp = fopen($settings_csv_path, 'w');
        if ($fp !== false) {
            fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
            fputcsv($fp, array('setting_key', 'setting_value'));
            foreach ($settings_keys as $key) {
                $value = get_option($key, '');
                if (is_array($value)) {
                    $value = json_encode($value, JSON_UNESCAPED_UNICODE);
                }
                fputcsv($fp, array($key, $value));
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing stream/file handle after CSV generation.
            fclose($fp);
            $csv_files[] = $settings_csv_path;
            $file_count++;
        }
        
        // Create ZIP file
        $site_name = sanitize_file_name(get_bloginfo('name'));
        $date = gmdate('Y-m-d');
        $zip_filename = "freelance-studio-export-{$site_name}-{$date}.zip";
        $zip_path = $temp_dir . '/' . $zip_filename;
        
        $zip = new ZipArchive();
        if ($zip->open($zip_path, ZipArchive::CREATE) !== true) {
            // Clean up
            foreach ( $csv_files as $csv_file ) {
                wp_delete_file( $csv_file );
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removing a temporary directory created by this function.
            rmdir($temp_dir);
            wp_send_json_error(__('Could not create ZIP file', 'freelance-studio'));
        }
        
        foreach ($csv_files as $csv_file) {
            $zip->addFile($csv_file, basename($csv_file));
        }
        
        $zip->close();
        
        // Read ZIP file and encode as base64
        $zip_data = file_get_contents($zip_path);
        $zip_base64 = base64_encode($zip_data);
        
        // Clean up temp files
        foreach ($csv_files as $csv_file) {
            wp_delete_file($csv_file);
        }
        wp_delete_file($zip_path);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removing a temporary directory created by this function.
        @rmdir($temp_dir);
        
        wp_send_json_success(array(
            'filename' => $zip_filename,
            'zip_data' => $zip_base64,
            'file_count' => $file_count,
        ));
    }

    /**
     * Manually start a new retainer period for a project (v7.10.20)
     * Ends the current period today and opens a fresh one starting tomorrow.
     */
    public function reset_retainer_period() {
        $this->verify_nonce();
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __('Permission denied.', 'freelance-studio')));
        }

        $project_id = intval($_POST['project_id'] ?? 0);
        if (!$project_id) {
            wp_send_json_error(array('message' => __('Invalid project.', 'freelance-studio')));
        }

        global $wpdb;
        $periods_table = $wpdb->prefix . 'fs_retainer_periods';
        // Use wp_date() to respect the site timezone (avoids UTC offset issues, e.g. UTC-5 at night)
        $today   = wp_date('Y-m-d');
        $project = FS_Project::get($project_id);

        if (!$project || !$project->is_retainer) {
            wp_send_json_error(array('message' => __('Not a retainer project.', 'freelance-studio')));
        }

        // Close the current period ending today
        $current = FS_Retainer_Period::get_current_period($project_id);
        if ($current) {
            FS_Retainer_Period::sync_hours_from_time_entries($project_id);
            $wpdb->update(
                $periods_table,
                array('period_end' => $today),
                array('id' => $current->id),
                array('%s'),
                array('%d')
            );
        }

        // New period starts tomorrow
        $new_start = wp_date('Y-m-d', strtotime($today . ' +1 day'));

        // Respect the configured reset day pattern (same logic as automatic cron)
        $reference_day = 1;
        if (!empty($project->retainer_reset_day)) {
            $reference_day = intval($project->retainer_reset_day);
        } elseif (!empty($project->billing_start_date)) {
            $reference_day = intval(gmdate('j', strtotime($project->billing_start_date)));
        } elseif (!empty($project->start_date)) {
            $reference_day = intval(gmdate('j', strtotime($project->start_date)));
        }
        $reference_day = max(1, min(28, $reference_day));

        // new_end = the day before the next occurrence of reference_day on or after new_start
        $ns_ts    = strtotime($new_start);
        $ns_day   = intval(wp_date('j', $ns_ts));
        $ns_month = intval(wp_date('n', $ns_ts));
        $ns_year  = intval(wp_date('Y', $ns_ts));

        // Find the next reference_day strictly after new_start
        if ($ns_day < $reference_day) {
            $next_ref_ts = mktime(0, 0, 0, $ns_month, $reference_day, $ns_year);
        } else {
            // reference_day is today or earlier this month — push to next month
            $next_ref_ts = mktime(0, 0, 0, $ns_month + 1, $reference_day, $ns_year);
        }
        $new_end = wp_date('Y-m-d', $next_ref_ts - DAY_IN_SECONDS);

        $new_period_id = FS_Retainer_Period::generate_period($project_id, $new_start, $new_end);

        if (!$new_period_id) {
            wp_send_json_error(array('message' => __('Failed to create new period.', 'freelance-studio')));
        }

        wp_send_json_success(array(
            'message'    => sprintf(
                /* translators: 1. period start date, 2. period end date */
                __('New period started: %1$s – %2$s', 'freelance-studio'),
                date_i18n(get_option('date_format'), strtotime($new_start)),
                date_i18n(get_option('date_format'), strtotime($new_end))
            ),
            'period_start' => $new_start,
            'period_end'   => $new_end,
        ));
    }

    /**
     * Check if a project's hour cap has been reached and send notification (v7.9.0)
     *
     * @param int $project_id Project ID
     */
    private function check_hour_cap_notification($project_id) {
        $capped_service = FS_Project::get_capped_hourly_service($project_id);
        if (!$capped_service) {
            return;
        }

        $hour_cap = floatval($capped_service['hour_cap']);
        if ($hour_cap <= 0) {
            return;
        }

        // Calculate tracked hours
        $time_summary = FS_Time_Entry::get_summary(array('project_id' => $project_id));
        $tracked_hours = floatval(($time_summary->total_seconds ?? 0) / 3600);

        $project = FS_Project::get($project_id);
        if (!$project) {
            return;
        }

        $overage_rate = floatval($capped_service['hour_cap_overage_rate'] ?? 0);
        if ($overage_rate <= 0) {
            $overage_rate = floatval($capped_service['rate'] ?? 0);
        }

        $notify_freelancer = !empty($capped_service['hour_cap_notify_freelancer']);
        $notify_client     = !empty($capped_service['hour_cap_notify_client']);
        $warn_threshold    = intval($capped_service['hour_cap_warn_threshold'] ?? 0);

        $business_name = get_option('fs_business_name', get_bloginfo('name'));
        $admin_email   = get_option('admin_email');

        // --- Early warning ---
        if ($warn_threshold >= 1 && $warn_threshold <= 99) {
            $warn_hours     = ($warn_threshold / 100) * $hour_cap;
            $warn_notified  = get_option('fs_hour_cap_warn_notified_' . $project_id, 0);
            $at_cap_reached = get_option('fs_hour_cap_notified_' . $project_id, 0);

            // Send warning only once, only if not yet at full cap notification
            if ($tracked_hours >= $warn_hours && floatval($warn_notified) < $warn_hours && floatval($at_cap_reached) < $hour_cap) {
                update_option('fs_hour_cap_warn_notified_' . $project_id, $warn_hours);

                $pct_display  = $warn_threshold . '%';
                $warn_subject = sprintf(
                    /* translators: 1. percentage used, 2. hour budget, 3. project name */
                    __('[%1$s] %2$s of hour budget used on %3$s', 'freelance-studio'),
                    $business_name,
                    $pct_display,
                    $project->name
                );

                if ($notify_freelancer) {
                    $content  = sprintf(
                        /* translators: 1. percentage used, 2. hour budget, 3. project name */
                        __('Early warning: <strong>%1$s</strong> of your %2$s-hour cap has been used on <strong>%3$s</strong>.', 'freelance-studio'),
                        $pct_display,
                        number_format($hour_cap, 1),
                        esc_html($project->name)
                    );
                    $content .= '<br><br>';
                    $content .= sprintf(
                        /* translators: 1. date/time, 2. date/time */
                        __('Tracked so far: <strong>%1$s hours</strong> — %2$s hours remaining before cap.', 'freelance-studio'),
                        number_format($tracked_hours, 1),
                        number_format(max(0, $hour_cap - $tracked_hours), 1)
                    );
                    $project_url = admin_url('admin.php?page=fs-projects&action=view&id=' . $project_id);
                    $content .= '<br><br>';
                    $content .= '<a href="' . esc_url($project_url) . '" style="display:inline-block;padding:10px 20px;background:#1FBBA6;color:#fff;text-decoration:none;border-radius:6px;">' . __('View Project', 'freelance-studio') . '</a>';

                    FS_Email_Service::send_styled_email(
                        $admin_email,
                        $warn_subject,
                        /* translators: 1. hour budget value */
                        sprintf(__('%s Hour Budget Warning', 'freelance-studio'), $pct_display),
                        $content
                    );
                }

                if ($notify_client && $project->client_id) {
                    $this->send_cap_email_to_client(
                        $project,
                        $capped_service,
                        'warning',
                        $tracked_hours,
                        $hour_cap,
                        $overage_rate,
                        $warn_threshold
                    );
                }
            }
        }

        // --- Full cap reached ---
        $notified_key = 'fs_hour_cap_notified_' . $project_id;
        $notified_cap = get_option($notified_key, 0);

        if (floatval($notified_cap) >= $hour_cap) {
            return; // Already notified for this cap
        }

        if ($tracked_hours < $hour_cap) {
            return; // Not yet at cap
        }

        // Mark as notified
        update_option($notified_key, $hour_cap);

        $overage_hours = $tracked_hours - $hour_cap;

        // Email to freelancer (always sent — either by explicit checkbox or as before)
        if ($notify_freelancer || !$notify_client) {
            // Send to freelancer when: explicitly checked, OR client notify is also off (preserve old behaviour)
        }
        // Simplest correct rule: send freelancer email if notify_freelancer OR neither box is checked (legacy behaviour)
        $send_to_freelancer = $notify_freelancer || (!$notify_freelancer && !$notify_client);

        if ($send_to_freelancer) {
            $subject = sprintf(
                /* translators: 1. hour cap amount, 2. project name */
                __('[%1$s] Hour cap reached on %2$s', 'freelance-studio'),
                $business_name,
                $project->name
            );

            $content  = sprintf(
                /* translators: 1. hour cap amount, 2. project name */
                __('You\'ve reached your %1$s-hour cap on <strong>%2$s</strong>.', 'freelance-studio'),
                number_format($hour_cap, 1),
                esc_html($project->name)
            );
            $content .= '<br><br>';
            $content .= sprintf(
                /* translators: 1. date/time, 2. date/time */
                __('Total tracked: <strong>%1$s hours</strong> (%2$s hours over cap)', 'freelance-studio'),
                number_format($tracked_hours, 1),
                number_format($overage_hours, 1)
            );
            $content .= '<br><br>';
            $content .= sprintf(
                /* translators: 1. recipient name */
                __('Time tracked beyond this point will be flagged as overage at %s/hr when invoicing.', 'freelance-studio'),
                FS_View_Context::format_currency($overage_rate)
            );
            $project_url = admin_url('admin.php?page=fs-projects&action=view&id=' . $project_id);
            $content .= '<br><br>';
            $content .= '<a href="' . esc_url($project_url) . '" style="display:inline-block;padding:10px 20px;background:#1FBBA6;color:#fff;text-decoration:none;border-radius:6px;">' . __('View Project', 'freelance-studio') . '</a>';

            FS_Email_Service::send_styled_email(
                $admin_email,
                $subject,
                __('Hour Cap Reached', 'freelance-studio'),
                $content
            );
        }

        // Email to client
        if ($notify_client && $project->client_id) {
            $this->send_cap_email_to_client(
                $project,
                $capped_service,
                'cap',
                $tracked_hours,
                $hour_cap,
                $overage_rate,
                0
            );
        }
    }

    /**
     * Send hour-cap-related email to the client contact.
     *
     * @param object $project
     * @param array  $capped_service
     * @param string $type            'cap' or 'warning'
     * @param float  $tracked_hours
     * @param float  $hour_cap
     * @param float  $overage_rate
     * @param int    $warn_threshold  Percentage (only used for 'warning' type)
     */
    private function send_cap_email_to_client($project, $capped_service, $type, $tracked_hours, $hour_cap, $overage_rate, $warn_threshold) {
        $client = FS_Client::get($project->client_id);
        if (!$client) {
            return;
        }

        $notify_contact_id = $capped_service['hour_cap_notify_contact_id'] ?? 'primary';
        $recipient_email   = '';
        $recipient_name    = '';

        if ($notify_contact_id !== 'primary' && is_numeric($notify_contact_id)) {
            $contact = FS_Client_Contact::get(intval($notify_contact_id));
            if ($contact && !empty($contact->email)) {
                $recipient_email = $contact->email;
                $recipient_name  = $contact->name;
            }
        }

        if (empty($recipient_email) && $client->email) {
            $recipient_email = $client->email;
            $recipient_name  = $client->name;
        }

        if (!$recipient_email) {
            return;
        }

        if ($type === 'warning') {
            $pct_display    = $warn_threshold . '%';
            $client_subject = sprintf(
                /* translators: 1. date/time */
                __('Hour budget update for %s', 'freelance-studio'),
                $project->name
            );
            /* translators: 1. recipient name */
            $client_content  = sprintf(__('Hi %s,', 'freelance-studio'), esc_html($recipient_name));
            $client_content .= '<br><br>';
            $client_content .= sprintf(
                /* translators: 1. percentage used, 2. hour budget, 3. project name, 4. hours tracked */
                __('Just a heads-up: <strong>%1$s</strong> of the %2$s-hour budget for <strong>%3$s</strong> has now been used (%4$s hours tracked so far).', 'freelance-studio'),
                $pct_display,
                number_format($hour_cap, 1),
                esc_html($project->name),
                number_format($tracked_hours, 1)
            );
            $client_content .= '<br><br>';
            $client_content .= sprintf(
                /* translators: 1. date/time */
                __('There are approximately <strong>%s hours</strong> remaining in the current budget. Please reach out if you have any questions.', 'freelance-studio'),
                number_format(max(0, $hour_cap - $tracked_hours), 1)
            );
            /* translators: 1. hours used value */
            $heading = sprintf(__('%s Hours Used', 'freelance-studio'), $pct_display);
        } else {
            $client_subject = sprintf(
                /* translators: 1. date/time */
                __('Hour budget update for %s', 'freelance-studio'),
                $project->name
            );
            /* translators: 1. recipient name */
            $client_content  = sprintf(__('Hi %s,', 'freelance-studio'), esc_html($recipient_name));
            $client_content .= '<br><br>';
            $client_content .= sprintf(
                /* translators: 1. hour cap amount, 2. hourly rate, 3. overage rate */
                __('This is a notification that the %1$s-hour budget for <strong>%2$s</strong> has been reached. Work will continue, and any additional time will be billed at the overage rate of %3$s/hr.', 'freelance-studio'),
                number_format($hour_cap, 1),
                esc_html($project->name),
                FS_View_Context::format_currency($overage_rate)
            );
            $client_content .= '<br><br>';
            $client_content .= __('Please reach out if you have any questions or would like to discuss the project scope.', 'freelance-studio');
            $heading = __('Hours Budget Update', 'freelance-studio');
        }

        FS_Email_Service::send_styled_email($recipient_email, $client_subject, $heading, $client_content);
    }

    /**
     * Submit a feature request — emails it to the plugin team
     */
    public function submit_feature_request() {
        check_ajax_referer( 'fs_feature_request', 'nonce' );

        $name        = sanitize_text_field( wp_unslash( $_POST['fr_name']        ?? '' ) );
        $email       = sanitize_email(      wp_unslash( $_POST['fr_email']       ?? '' ) );
        $category    = sanitize_text_field( wp_unslash( $_POST['fr_category']    ?? '' ) );
        $title       = sanitize_text_field( wp_unslash( $_POST['fr_title']       ?? '' ) );
        $description = sanitize_textarea_field( wp_unslash( $_POST['fr_description'] ?? '' ) );
        $priority    = sanitize_text_field( wp_unslash( $_POST['fr_priority']    ?? 'would-use-it' ) );

        if ( ! $name || ! $category || ! $title || ! $description ) {
            wp_send_json_error( __( 'Please fill in all required fields.', 'freelance-studio' ) );
        }

        $site_url    = home_url();
        $submitted_by = wp_get_current_user()->user_login ?: 'unknown';

        $subject = sprintf( '[FS Feature Request] [%s] %s', $category, $title );

        $body  = "A new feature request has been submitted via Freelance Studio.\n\n";
        $body .= "Name:        {$name}\n";
        $body .= "Email:       " . ( $email ?: '(not provided)' ) . "\n";
        $body .= "WP User:     {$submitted_by}\n";
        $body .= "Site:        {$site_url}\n";
        $body .= "Plugin ver:  " . JEEB_FS_VERSION . "\n\n";
        $body .= "Category:    {$category}\n";
        $body .= "Priority:    {$priority}\n\n";
        $body .= "Title:\n{$title}\n\n";
        $body .= "Description:\n{$description}\n";

        $headers = array( 'Content-Type: text/plain; charset=UTF-8' );
        if ( $email ) {
            $headers[] = "Reply-To: {$name} <{$email}>";
        }

        $sent = wp_mail( 'admin@freelancestud.io', $subject, $body, $headers );

        if ( $sent ) {
            wp_send_json_success( array( 'message' => __( 'Feature request submitted — thank you!', 'freelance-studio' ) ) );
        } else {
            wp_send_json_error( __( 'The email could not be sent. Please try again later.', 'freelance-studio' ) );
        }
    }

    /**
     * Merge client preview - get record counts for source client
     * 
     * @since 8.3.0
     */
    public function merge_client_preview() {
        $this->verify_nonce();
        $this->verify_capability('manage_options');

        $source_id = isset($_POST['source_id']) ? intval($_POST['source_id']) : 0;
        if (!$source_id) {
            wp_send_json_error(array('message' => __('Invalid client ID.', 'freelance-studio')));
        }

        global $wpdb;
        $p = $wpdb->prefix . 'fs_';

        wp_send_json_success(array(
            'projects'  => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}projects WHERE client_id = %d", $source_id)),
            'invoices'  => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}invoices WHERE client_id = %d", $source_id)),
            'proposals' => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}proposals WHERE client_id = %d", $source_id)),
            'contracts' => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}contracts WHERE client_id = %d", $source_id)),
            'expenses'  => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}expenses WHERE client_id = %d", $source_id)),
            'tasks'     => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}tasks WHERE client_id = %d", $source_id)),
            'bookings'  => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}bookings WHERE client_id = %d", $source_id)),
            'contacts'  => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$p}client_contacts WHERE client_id = %d", $source_id)),
        ));
    }

    /**
     * Merge client - reassign all records from source to target, then delete source
     * 
     * @since 8.3.0
     */
    public function merge_client() {
        $this->verify_nonce();
        $this->verify_capability('manage_options');

        $source_id = isset($_POST['source_id']) ? intval($_POST['source_id']) : 0;
        $target_id = isset($_POST['target_id']) ? intval($_POST['target_id']) : 0;

        if (!$source_id || !$target_id) {
            wp_send_json_error(array('message' => __('Invalid client IDs.', 'freelance-studio')));
        }

        if ($source_id === $target_id) {
            wp_send_json_error(array('message' => __('Source and target client cannot be the same.', 'freelance-studio')));
        }

        // Verify both clients exist
        $source = FS_Client::get($source_id);
        $target = FS_Client::get($target_id);
        if (!$source || !$target) {
            wp_send_json_error(array('message' => __('One or both clients not found.', 'freelance-studio')));
        }

        global $wpdb;
        $p = $wpdb->prefix . 'fs_';

        // Reassign all related records
        $tables_with_client_id = array(
            'projects',
            'invoices',
            'proposals',
            'contracts',
            'expenses',
            'tasks',
            'bookings',
            'client_contacts',
        );

        $reassigned = 0;
        foreach ($tables_with_client_id as $table) {
            $result = $wpdb->update(
                $p . $table,
                array('client_id' => $target_id),
                array('client_id' => $source_id),
                array('%d'),
                array('%d')
            );
            if ($result !== false) {
                $reassigned += $result;
            }
        }

        // Also reassign portal tokens if they exist
        $tokens_table = $p . 'portal_tokens';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$tokens_table}'") === $tokens_table) {
            $wpdb->update(
                $tokens_table,
                array('client_id' => $target_id),
                array('client_id' => $source_id),
                array('%d'),
                array('%d')
            );
        }

        // Delete the source client (now has no related records)
        $deleted = FS_Client::delete($source_id);

        if ($deleted === false) {
            // Fallback direct delete if the model method fails (it checks for related records)
            $wpdb->delete($p . 'clients', array('id' => $source_id), array('%d'));
        }

        wp_send_json_success(array(
            'message' => sprintf(
                /* translators: 1: source client name, 2: target client name, 3: reassigned record count */
                __('%1$s merged into %2$s. %3$d records reassigned.', 'freelance-studio'),
                FS_Client::get_display_name($source),
                FS_Client::get_display_name($target),
                $reassigned
            ),
            'reassigned' => $reassigned,
        ));
    }

    /**
     * Import preview - check for existing clients, projects, invoice numbers
     * 
     * @since 8.3.0
     */
    public function import_preview() {
        check_ajax_referer('fs_import_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        global $wpdb;
        $table_prefix = $wpdb->prefix . 'fs_';

        // Check existing clients (case-insensitive match on company_name)
        $client_names = json_decode(stripslashes($_POST['clients']), true);
        $existing_clients = array();
        if (!empty($client_names)) {
            $placeholders = implode(',', array_fill(0, count($client_names), '%s'));
            $lower_names = array_map('strtolower', $client_names);
            $results = $wpdb->get_col(
                $wpdb->prepare(
                    "SELECT LOWER(company_name) FROM {$table_prefix}clients WHERE LOWER(company_name) IN ($placeholders)",
                    ...$lower_names
                )
            );
            $existing_clients = $results ?: array();
        }

        // Check existing invoice numbers
        $invoice_numbers = json_decode(stripslashes($_POST['invoice_numbers']), true);
        $existing_invoices = array();
        if (!empty($invoice_numbers)) {
            $invoice_numbers = array_filter($invoice_numbers);
            if (!empty($invoice_numbers)) {
                $placeholders = implode(',', array_fill(0, count($invoice_numbers), '%s'));
                $results = $wpdb->get_col(
                    $wpdb->prepare(
                        "SELECT invoice_number FROM {$table_prefix}invoices WHERE invoice_number IN ($placeholders)",
                        ...$invoice_numbers
                    )
                );
                $existing_invoices = $results ?: array();
            }
        }

        // Check existing projects (match on client_name||project_name key)
        $project_keys = json_decode(stripslashes($_POST['projects']), true);
        $existing_projects = array();
        if (!empty($project_keys)) {
            // Get all project names with their client names
            $all_projects = $wpdb->get_results(
                "SELECT p.name AS project_name, c.company_name AS client_name 
                 FROM {$table_prefix}projects p 
                 LEFT JOIN {$table_prefix}clients c ON p.client_id = c.id",
                ARRAY_A
            );
            if ($all_projects) {
                foreach ($all_projects as $p) {
                    $existing_projects[] = strtolower($p['client_name'] . '||' . $p['project_name']);
                }
            }
        }

        wp_send_json_success(array(
            'clients' => $existing_clients,
            'invoice_numbers' => $existing_invoices,
            'projects' => $existing_projects,
        ));
    }

    /**
     * Import a batch of invoices (with auto-creation of clients and projects)
     * 
     * @since 8.3.0
     */
    public function import_batch() {
        check_ajax_referer('fs_import_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        global $wpdb;
        $table_prefix = $wpdb->prefix . 'fs_';

        $batch = json_decode(stripslashes($_POST['batch']), true);
        $projects_completed = !empty($_POST['projects_completed']);

        if (empty($batch) || !is_array($batch)) {
            wp_send_json_error(__('Invalid batch data.', 'freelance-studio'));
        }

        $clients_created = 0;
        $projects_created = 0;
        $invoices_created = 0;
        $errors = array();

        // Cache for client and project lookups within this batch
        $client_cache = array();
        $project_cache = array();

        foreach ($batch as $row) {
            try {
                $client_name = isset($row['client_name']) ? trim($row['client_name']) : '';
                if (empty($client_name)) {
                    $errors[] = __('Row skipped: missing client name.', 'freelance-studio');
                    continue;
                }

                // --- Find or create client ---
                $client_key = strtolower($client_name);
                if (isset($client_cache[$client_key])) {
                    $client_id = $client_cache[$client_key];
                } else {
                    $client_id = $wpdb->get_var(
                        $wpdb->prepare(
                            "SELECT id FROM {$table_prefix}clients WHERE LOWER(company_name) = %s LIMIT 1",
                            $client_key
                        )
                    );
                    if (!$client_id) {
                        $client_id = FS_Client::create(array(
                            'company_name' => $client_name,
                            'status' => 'active',
                        ));
                        if ($client_id) {
                            $clients_created++;
                        } else {
                            $errors[] = sprintf(__('Failed to create client: %s', 'freelance-studio'), $client_name);
                            continue;
                        }
                    }
                    $client_cache[$client_key] = $client_id;
                }

                // --- Find or create project ---
                $project_id = null;
                $project_name = isset($row['project_name']) ? trim($row['project_name']) : '';
                if (!empty($project_name)) {
                    $project_key = $client_key . '||' . strtolower($project_name);
                    if (isset($project_cache[$project_key])) {
                        $project_id = $project_cache[$project_key];
                    } else {
                        $project_id = $wpdb->get_var(
                            $wpdb->prepare(
                                "SELECT id FROM {$table_prefix}projects WHERE client_id = %d AND LOWER(name) = %s LIMIT 1",
                                $client_id,
                                strtolower($project_name)
                            )
                        );
                        if (!$project_id) {
                            $project_id = FS_Project::create(array(
                                'client_id' => $client_id,
                                'name' => $project_name,
                                'status' => $projects_completed ? 'completed' : 'active',
                                'billing_type' => 'fixed',
                            ));
                            if ($project_id) {
                                $projects_created++;
                            } else {
                                $errors[] = sprintf(__('Failed to create project: %s', 'freelance-studio'), $project_name);
                                // Continue anyway — invoice can be created without project
                                $project_id = null;
                            }
                        }
                        if ($project_id) {
                            $project_cache[$project_key] = $project_id;
                        }
                    }
                }

                // --- Create invoice ---
                $total = floatval($row['total'] ?? 0);
                $tax_total = floatval($row['tax_total'] ?? 0);
                $discount_total = floatval($row['discount_total'] ?? 0);
                $payment_total = floatval($row['payment_total'] ?? $total);
                $subtotal = $total - $tax_total + $discount_total;
                if ($subtotal <= 0) {
                    $subtotal = $total;
                }

                // Parse dates (handle M/D/Y format)
                $issue_date = self::parse_import_date($row['issue_date'] ?? '');
                $due_date = self::parse_import_date($row['due_date'] ?? '');
                $paid_date = self::parse_import_date($row['paid_date'] ?? '');
                $sent_date = self::parse_import_date($row['sent_date'] ?? '');

                // Determine status
                $status = 'draft';
                if ($paid_date && $payment_total >= $total) {
                    $status = 'paid';
                } elseif ($paid_date && $payment_total > 0) {
                    $status = 'partial';
                } elseif ($sent_date || !empty($row['sent_date'])) {
                    if ($due_date && strtotime($due_date) < time()) {
                        $status = 'overdue';
                    } else {
                        $status = 'sent';
                    }
                }

                $invoice_number = isset($row['invoice_number']) ? trim($row['invoice_number']) : '';

                // Build line items JSON
                $items = array(array(
                    'description' => $project_name ?: $client_name,
                    'quantity' => 1,
                    'rate' => $subtotal,
                    'amount' => $subtotal,
                ));

                $invoice_data = array(
                    'client_id' => $client_id,
                    'project_id' => $project_id,
                    'invoice_number' => $invoice_number,
                    'invoice_date' => $issue_date ?: null,
                    'due_date' => $due_date ?: ($issue_date ?: current_time('Y-m-d')),
                    'paid_date' => $paid_date ?: null,
                    'status' => $status,
                    'subtotal' => $subtotal,
                    'tax_rate' => 0,
                    'discount_type' => $discount_total > 0 ? 'fixed' : null,
                    'discount_value' => $discount_total,
                    'amount_paid' => $payment_total,
                    'notes' => isset($row['notes']) ? $row['notes'] : '',
                    'items' => $items,
                );

                $invoice_id = FS_Invoice::create($invoice_data);
                if ($invoice_id) {
                    $invoices_created++;
                } else {
                    $errors[] = sprintf(
                        __('Failed to create invoice #%s for %s', 'freelance-studio'),
                        $invoice_number,
                        $client_name
                    );
                }

            } catch (Exception $e) {
                $errors[] = $e->getMessage();
            }
        }

        wp_send_json_success(array(
            'clients_created' => $clients_created,
            'projects_created' => $projects_created,
            'invoices_created' => $invoices_created,
            'errors' => $errors,
        ));
    }

    /**
     * Import a batch of expenses
     * 
     * @since 8.3.3
     */
    public function import_expenses_batch() {
        check_ajax_referer('fs_import_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        $batch = json_decode(stripslashes($_POST['batch']), true);

        if (empty($batch) || !is_array($batch)) {
            wp_send_json_error(__('Invalid batch data.', 'freelance-studio'));
        }

        $expenses_created = 0;
        $errors = array();

        foreach ($batch as $row) {
            try {
                $amount = floatval($row['amount'] ?? 0);
                if ($amount <= 0) {
                    $errors[] = __('Row skipped: invalid or zero amount.', 'freelance-studio');
                    continue;
                }

                $expense_date = self::parse_import_date($row['expense_date'] ?? '');
                if (!$expense_date) {
                    $expense_date = current_time('Y-m-d');
                }

                // Build expense name from merchant + description
                $merchant = isset($row['merchant']) ? trim($row['merchant']) : '';
                $description = isset($row['description']) ? trim($row['description']) : '';
                $expense_name = $merchant;
                if ($description && $description !== $merchant) {
                    $expense_name = $merchant ? $merchant . ' — ' . $description : $description;
                }
                if (empty($expense_name)) {
                    $expense_name = isset($row['category']) ? trim($row['category']) : __('Imported expense', 'freelance-studio');
                }

                $category = isset($row['category']) ? trim($row['category']) : '';
                $notes = isset($row['notes']) ? trim($row['notes']) : '';

                // Add sales tax info to notes if present
                $sales_tax = floatval($row['sales_tax'] ?? 0);
                if ($sales_tax > 0 && empty($notes)) {
                    $notes = sprintf(__('Sales tax: $%s', 'freelance-studio'), number_format($sales_tax, 2));
                }

                $expense_data = array(
                    'expense_date' => $expense_date,
                    'expense_name' => $expense_name,
                    'category' => $category,
                    'amount' => $amount,
                    'notes' => $notes,
                    'is_billable' => 0,
                );

                $expense_id = FS_Expense::create($expense_data);
                if ($expense_id) {
                    $expenses_created++;
                } else {
                    $errors[] = sprintf(
                        __('Failed to create expense: %s (%s)', 'freelance-studio'),
                        $expense_name,
                        $expense_date
                    );
                }
            } catch (Exception $e) {
                $errors[] = $e->getMessage();
            }
        }

        wp_send_json_success(array(
            'expenses_created' => $expenses_created,
            'errors' => $errors,
        ));
    }

    /**
     * Parse date from various formats (M/D/Y, Y-m-d, etc.)
     *
     * @since 8.3.0
     * @param string $date_str
     * @return string|null Y-m-d format or null
     */
    private static function parse_import_date($date_str) {
        $date_str = trim($date_str);
        if (empty($date_str)) {
            return null;
        }
        // Try M/D/Y format first (common in US CSV exports)
        if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{2,4})$#', $date_str, $m)) {
            $year = strlen($m[3]) === 2 ? '20' . $m[3] : $m[3];
            return sprintf('%04d-%02d-%02d', $year, $m[1], $m[2]);
        }
        // Try Y-m-d
        if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $date_str)) {
            return $date_str;
        }
        // Fallback: let PHP try to parse it
        $ts = strtotime($date_str);
        if ($ts !== false) {
            return date('Y-m-d', $ts);
        }
        return null;
    }

    /**
     * Get transaction details + match suggestions for sidebar panel
     */
    public function get_transaction_details() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $id = intval($_POST['id']);
        $transaction = FS_Transaction::get($id);
        if (!$transaction) {
            wp_send_json_error(__('Transaction not found.', 'freelance-studio'));
        }

        $data = array(
            'transaction' => $transaction,
            'suggestions' => array(),
            'linked'      => null,
            'category_suggestion' => null,
        );

        // Get match suggestions
        if ($transaction->type === 'debit' && $transaction->status === 'unmatched') {
            $data['suggestions'] = FS_Transaction::find_expense_matches($id);
        } elseif ($transaction->type === 'credit' && $transaction->status === 'unmatched') {
            $data['suggestions'] = FS_Transaction::find_invoice_matches($id);
        }

        // Get linked record if matched
        if ($transaction->status === 'matched') {
            if ($transaction->linked_expense_id) {
                $data['linked'] = array(
                    'type'   => 'expense',
                    'record' => FS_Expense::get($transaction->linked_expense_id),
                );
            } elseif ($transaction->linked_invoice_id) {
                $invoice = FS_Invoice::get($transaction->linked_invoice_id);
                if ($invoice) {
                    $client = FS_Client::get($invoice->client_id);
                    $invoice->client_name = $client ? FS_Client::get_display_name($client) : '';
                    if ($invoice->project_id) {
                        $project = FS_Project::get($invoice->project_id);
                        $invoice->project_name = $project ? $project->name : '';
                    }
                }
                $data['linked'] = array(
                    'type'   => 'invoice',
                    'record' => $invoice,
                );
            }
        }

        // Category suggestion from learned mappings
        if ($transaction->type === 'debit') {
            $data['category_suggestion'] = self::get_category_suggestion($transaction->description);
        }

        wp_send_json_success($data);
    }

    /**
     * Match a transaction to an expense or invoice
     */
    public function match_transaction() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $txn_id = intval($_POST['transaction_id']);
        $match_type = sanitize_text_field($_POST['match_type']);
        $match_id = intval($_POST['match_id']);
        $mark_paid = isset($_POST['mark_paid']) && $_POST['mark_paid'] === '1';

        $transaction = FS_Transaction::get($txn_id);
        if (!$transaction) {
            wp_send_json_error(__('Transaction not found.', 'freelance-studio'));
        }

        if ($match_type === 'expense') {
            $result = FS_Transaction::link_to_expense($txn_id, $match_id);
        } elseif ($match_type === 'invoice') {
            $result = FS_Transaction::link_to_invoice($txn_id, $match_id, $mark_paid);
        } else {
            wp_send_json_error(__('Invalid match type.', 'freelance-studio'));
            return;
        }

        wp_send_json_success(array('matched' => $result));
    }

    /**
     * Unlink a matched transaction
     */
    public function unmatch_transaction() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $txn_id = intval($_POST['transaction_id']);
        $result = FS_Transaction::unlink($txn_id);
        wp_send_json_success(array('unlinked' => $result));
    }

    /**
     * Ignore (mark as personal) or unignore a transaction
     */
    public function toggle_transaction_ignore() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $txn_id = intval($_POST['transaction_id']);
        $action_type = sanitize_text_field($_POST['action_type']);

        if ($action_type === 'ignore') {
            FS_Transaction::ignore($txn_id);
        } else {
            FS_Transaction::unignore($txn_id);
        }

        wp_send_json_success();
    }

    /**
     * Create expense from a debit transaction
     */
    public function create_expense_from_transaction() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $txn_id = intval($_POST['transaction_id']);
        $transaction = FS_Transaction::get($txn_id);
        if (!$transaction) {
            wp_send_json_error(__('Transaction not found.', 'freelance-studio'));
        }

        $category = sanitize_text_field($_POST['category']);
        $vendor = sanitize_text_field($_POST['vendor']);
        $project_id = intval($_POST['project_id']);

        $expense_data = array(
            'expense_date' => $transaction->transaction_date,
            'expense_name' => $vendor ?: $transaction->description,
            'category'     => $category,
            'amount'       => abs($transaction->amount),
            'project_id'   => $project_id ?: null,
            'is_billable'  => 0,
            'notes'        => sprintf(
                /* translators: %s: bank reference */
                __('Created from bank transaction. Ref: %s', 'freelance-studio'),
                $transaction->bank_reference
            ),
        );

        $expense_id = FS_Expense::create($expense_data);
        if (!$expense_id) {
            wp_send_json_error(__('Failed to create expense.', 'freelance-studio'));
        }

        // Link the transaction to the new expense
        FS_Transaction::link_to_expense($txn_id, $expense_id);

        // Learn the category mapping
        if ($category) {
            self::learn_category_mapping($transaction->description, $category);
        }

        wp_send_json_success(array('expense_id' => $expense_id));
    }

    /**
     * Save a category for a transaction (for learning)
     */
    public function save_transaction_category() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');

        $txn_id = intval($_POST['transaction_id']);
        $category = sanitize_text_field($_POST['category']);

        FS_Transaction::update($txn_id, array('category' => $category));

        $transaction = FS_Transaction::get($txn_id);
        if ($transaction && $category) {
            self::learn_category_mapping($transaction->description, $category);
        }

        wp_send_json_success();
    }

    /**
     * Get a category suggestion for a bank description
     */
    private static function get_category_suggestion($description) {
        $mappings = get_option('fs_transaction_category_map', array());
        if (empty($mappings)) return null;

        $desc_lower = strtolower($description);
        $best_match = null;
        $best_score = 0;

        foreach ($mappings as $keyword => $category) {
            if (strpos($desc_lower, strtolower($keyword)) !== false) {
                $score = strlen($keyword);
                if ($score > $best_score) {
                    $best_score = $score;
                    $best_match = $category;
                }
            }
        }

        return $best_match;
    }

    /**
     * Learn a category mapping from a transaction description
     */
    private static function learn_category_mapping($description, $category) {
        $mappings = get_option('fs_transaction_category_map', array());

        // Extract meaningful keywords from description
        // Remove common prefixes, numbers, dates
        $desc = strtolower(trim($description));
        $desc = preg_replace('/\d{2,}/', '', $desc);
        $desc = preg_replace('/\b(pos|ach|wire|transfer|payment|purchase|debit|credit|card)\b/i', '', $desc);
        $desc = preg_replace('/[#*\/\\\\]/', ' ', $desc);
        $desc = preg_replace('/\s+/', ' ', trim($desc));

        // Split into words, take first 2-3 meaningful words as the keyword
        $words = array_filter(explode(' ', $desc), function($w) {
            return strlen($w) > 2;
        });

        if (count($words) >= 2) {
            $keyword = implode(' ', array_slice(array_values($words), 0, 3));
        } elseif (count($words) === 1) {
            $keyword = reset($words);
        } else {
            return;
        }

        $keyword = trim($keyword);
        if (strlen($keyword) < 3) return;

        $mappings[$keyword] = $category;

        // Keep mappings manageable (max 500)
        if (count($mappings) > 500) {
            $mappings = array_slice($mappings, -400, null, true);
        }

        update_option('fs_transaction_category_map', $mappings);
    }

    /**
     * Get personal transaction rules
     */
    public function get_personal_rules() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');
        $rules = get_option('fs_personal_transaction_rules', array());
        wp_send_json_success(array('rules' => $rules));
    }

    /**
     * Save personal transaction rules
     */
    public function save_personal_rules() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }
        $rules = json_decode(stripslashes($_POST['rules']), true);
        if (!is_array($rules)) $rules = array();
        $rules = array_map('sanitize_text_field', $rules);
        $rules = array_values(array_filter($rules));
        update_option('fs_personal_transaction_rules', $rules);
        wp_send_json_success();
    }

    /**
     * Run personal rules against all unmatched transactions
     */
    public function run_personal_rules() {
        check_ajax_referer('fs_ajax_nonce', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        $rules = get_option('fs_personal_transaction_rules', array());
        if (empty($rules)) {
            wp_send_json_success(array('ignored_count' => 0, 'ignored_ids' => array()));
        }

        $unmatched = FS_Transaction::get_all(array('status' => 'unmatched', 'limit' => 1000));
        $ignored_ids = array();

        foreach ($unmatched as $txn) {
            if (FS_Transaction::matches_personal_rules($txn->description, $rules)) {
                FS_Transaction::ignore($txn->id);
                $ignored_ids[] = $txn->id;
            }
        }

        wp_send_json_success(array(
            'ignored_count' => count($ignored_ids),
            'ignored_ids'   => $ignored_ids,
        ));
    }

    /**
     * Check for duplicate transactions before import
     */
    public function check_transaction_duplicates() {
        check_ajax_referer('fs_import_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        $transactions = json_decode(stripslashes($_POST['transactions']), true);
        if (!is_array($transactions)) {
            wp_send_json_error(__('Invalid data.', 'freelance-studio'));
        }

        $duplicates = array();
        foreach ($transactions as $t) {
            if (FS_Transaction::exists($t['date'], $t['amount'], $t['description'])) {
                $duplicates[] = $t['date'] . '|' . $t['amount'] . '|' . $t['description'];
            }
        }

        wp_send_json_success(array('duplicates' => $duplicates));
    }

    /**
     * Import a batch of bank transactions
     */
    public function import_transactions_batch() {
        check_ajax_referer('fs_import_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Permission denied.', 'freelance-studio'));
        }

        $transactions = json_decode(stripslashes($_POST['transactions']), true);
        $import_batch = sanitize_text_field($_POST['import_batch']);
        $source_bank = sanitize_text_field($_POST['source_bank']);

        if (!is_array($transactions) || empty($import_batch)) {
            wp_send_json_error(__('Invalid data.', 'freelance-studio'));
        }

        $result = FS_Transaction::create_batch($transactions, $import_batch, $source_bank);

        wp_send_json_success($result);
    }

    /**
     * Verify email delivery API key
     * @since 8.6.0
     */
    public function email_delivery_verify_key() {
        check_ajax_referer('fs_email_delivery', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __('Permission denied', 'freelance-studio')));
        }

        $provider = isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : '';
        $api_key  = isset($_POST['api_key']) ? sanitize_text_field(wp_unslash($_POST['api_key'])) : '';

        if (empty($provider) || empty($api_key)) {
            wp_send_json_error(array('message' => __('Provider and API key are required.', 'freelance-studio')));
        }

        $result = FS_Email_Delivery::verify_api_key($provider, $api_key);

        if (is_wp_error($result)) {
            wp_send_json_error(array('message' => $result->get_error_message()));
        }

        // Save the verified key
        update_option('fs_email_delivery_provider', $provider);
        update_option('fs_email_delivery_api_key', $api_key);

        wp_send_json_success(array('message' => __('Connection verified successfully!', 'freelance-studio')));
    }

    /**
     * Send test email via configured delivery
     * @since 8.6.0
     */
    public function email_delivery_send_test() {
        check_ajax_referer('fs_email_delivery', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __('Permission denied', 'freelance-studio')));
        }

        $to_email = get_option('fs_business_email', get_option('admin_email'));

        if (empty($to_email)) {
            wp_send_json_error(array('message' => __('No business email configured. Please set your email in Settings → Business first.', 'freelance-studio')));
        }

        $result = FS_Email_Delivery::send_test_email($to_email);

        if (is_wp_error($result)) {
            wp_send_json_error(array('message' => $result->get_error_message()));
        }

        // Log the test timestamp
        update_option('fs_email_delivery_last_test', current_time('mysql'));

        wp_send_json_success(array(
            'message' => sprintf(
                /* translators: %s: email address */
                __('Test email sent to %s. Check your inbox (and spam folder) to confirm delivery.', 'freelance-studio'),
                $to_email
            )
        ));
    }

    /**
     * Complete email delivery setup
     * @since 8.6.0
     */
    public function email_delivery_complete_setup() {
        check_ajax_referer('fs_email_delivery', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __('Permission denied', 'freelance-studio')));
        }

        $provider = isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : '';

        if (empty($provider)) {
            wp_send_json_error(array('message' => __('No provider selected.', 'freelance-studio')));
        }

        update_option('fs_email_delivery_provider', $provider);
        update_option('fs_email_delivery_setup_complete', '1');
        update_option('fs_email_delivery_fallback', '1');

        wp_send_json_success(array('message' => __('Email delivery configured successfully!', 'freelance-studio')));
    }
}

