<?php
// Plugin Check / PHPCS: category-level exclusions with reasons.
// Line-level findings in this file are handled individually below.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- PaintOps keeps its records in its own custom tables (built by paintops_table()); $wpdb is the supported API for plugin-owned tables, there is no core CRUD alternative, and results are request-scoped.
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only GET routing and filter parameters (which screen/record to show); every state-changing handler verifies a nonce before writing.
/**
 * Included File: Job Manager Notifications
 * Description: A custom CRON that notifies workers of upcoming new jobs
 * Version: 1.3
 * Author: You
 */

// Avoid direct access to the file
if (!defined('ABSPATH')) {
    exit;
}

add_action('init', 'paintops_maybe_run_notifications_from_url');

function paintops_ordinal_suffix($number) {
    if (!in_array(($number % 100), [11,12,13])) {
        switch ($number % 10) {
            case 1: return $number . 'st';
            case 2: return $number . 'nd';
            case 3: return $number . 'rd';
        }
    }
    return $number . 'th';
}

/**
 * URL-triggered entry point, used as a backup for sites where WP-Cron
 * (the primary trigger, scheduled in paintops.php) is unreliable due to
 * low traffic. Requires a secret per-install token so this can't be
 * fired by anyone who happens to guess the URL.
 *
 * Visiting the URL without a valid token does nothing and reveals
 * nothing about whether PaintOps is even installed.
 */
function paintops_maybe_run_notifications_from_url() {
    if (!isset($_GET['sendNotif']) || $_GET['sendNotif'] != 1) {
        return;
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- cron-style endpoint hit by an external scheduler with no WP session; authenticated below by hash_equals() against the per-install secret token, the same pattern as the calendar feed
    $provided_token = isset($_GET['token']) ? sanitize_text_field(wp_unslash((string) $_GET['token'])) : '';
    $expected_token = function_exists('paintops_notif_token') ? paintops_notif_token() : '';

    if ($expected_token === '' || !hash_equals($expected_token, $provided_token)) {
        return;
    }

    paintops_run_job_notifications(true);
    exit;
}

/**
 * Core notification logic. Called silently by the daily WP-Cron event
 * (paintops_daily_notifications, scheduled in paintops.php) and,
 * with progress output, by paintops_maybe_run_notifications_from_url() above
 * as a manual backup trigger.
 *
 * Deliberately does not exit() here so that when WP-Cron runs several
 * scheduled hooks in the same request, this one doesn't cut the rest off.
 */
function paintops_run_job_notifications(bool $echo_progress = false) {
    if (!function_exists('wp_mail')) {
        return; // WordPress is not ready
    }

    if (function_exists('paintops_notifications_enabled') && !paintops_notifications_enabled()) {
        if ($echo_progress) {
            echo 'Job notifications are turned off (PaintOps > Settings > Job Notification Trigger).';
        }
        return;
    }

    global $wpdb;
	$jobID = '';
    // (2026-07-19, audit P1, v1.0.104) The old query filtered
    // `notifsSent != 1`, a one-way per-JOB flag: once tomorrow's first
    // send happened, a worker ADDED or swapped in afterwards was never
    // emailed, exactly the evening-before reassignment case a multi-crew
    // shop hits weekly. Now every job starting tomorrow is selected and
    // the per-WORKER ledger (notified_worker_ids, '#id#' format) decides
    // who still needs an email, so re-runs only mail the newly added.
    // The job-save handler (manage-jobs.php) clears the ledger when the
    // start date changes, so a rescheduled job re-notifies everyone.
    $jobs = $wpdb->get_results($wpdb->prepare("
        SELECT *
        FROM %i
        WHERE STR_TO_DATE(CONCAT(startYear, '-', LPAD(startMonth, 2, '0'), '-', LPAD(startDay, 2, '0')), '%%Y-%%m-%%d') = CURDATE() + INTERVAL 1 DAY
    ", paintops_table('jobs')));

    foreach ($jobs as $job) {
        $userIds = paintops_parse_worker_ids($job->reqWorkers ?? '');
        $already_notified = paintops_parse_worker_ids((string) ($job->notified_worker_ids ?? ''));
        $userIds = array_values(array_diff($userIds, $already_notified));
        if (!$userIds) {
            continue; // everyone assigned has already been emailed for this date
        }
        $reqDays = $job->reqDays;
		$jobID = $job->id;
        $newly_notified = [];

        foreach ($userIds as $userId) {
            $userId = (int)$userId;
            if (!$userId) continue;
            $user = get_userdata($userId); // ✅ fixed here
            $email = $user ? $user->user_email : '';
			// first_name is only read once $user is confirmed to exist,
			// previously ran unconditionally and threw a PHP warning for a
			// stale/deleted worker ID left in a job's reqWorkers list.
			$first_name = $user ? $user->first_name : '';
            if ($email) {
                // Floor to 1 (Mike 2026-07-23: a "0 Day Job Begins
                // Tomorrow" email): reqDays is optional and follow-up
                // visits store 0, but no real job takes zero days.
                $subjectDays = max(1, (int) $reqDays);
                $subject = "$subjectDays Day Job Begins Tomorrow";
                $dayWithSuffix = paintops_ordinal_suffix((int)$job->startDay);
				$job_url = paintops_admin_url(['viewEid' => $jobID]);
				// No hardcoded state, this plugin has no state field, and
				// a literal "FL" here was wrong for any business outside
				// Florida (leftover from before the plugin was made
				// business-agnostic).
				$message = "Hi $first_name.\n\nA new job ({$job->jobTitle}) begins tomorrow, the {$dayWithSuffix} at {$job->startTime}.\nCustomer: {$job->custFirst} {$job->custLast}\nAddress: {$job->custServiceAddress} {$job->custServiceCity}\nPhone: {$job->custPhone}\n\nSee all the details at {$job_url}.\n\nIf you are asked to log in first, tap the Log In button and use your team username and password. You will land right back on this job.";
                $headers = function_exists('paintops_mail_headers') ? paintops_mail_headers() : ['From: PaintOps <' . get_option('admin_email') . '>'];

                $sent = wp_mail($email, $subject, $message, $headers);
                if ($sent) {
                    // Only a successful send goes on the ledger, a failed
                    // one is retried on the next run.
                    $newly_notified[] = $userId;
                }
                if ($echo_progress) {
                    if ($sent) {
                        echo esc_html("Mail sent to $first_name") . "<br>";
                    } else {
                        echo esc_html("Mail FAILED to $first_name") . "<br>";
                    }
                }
            }
        }

		// Append this run's successful sends to the per-worker ledger.
		// notifsSent stays maintained (back-compat with anything reading
		// it) but no longer gates the selection query above.
		$ledger = array_values(array_unique(array_merge($already_notified, $newly_notified)));
		$ledger_str = $ledger ? '#' . implode('##', array_map('intval', $ledger)) . '#' : '';
		$result = $wpdb->update(
			paintops_table('jobs'),
			['notified_worker_ids' => $ledger_str, 'notifsSent' => 1],
			['id' => $jobID],
			['%s', '%d'],
			['%d']
		);
		if ($wpdb->last_error) {
			paintops_report_db_error();
		}
	}
}