<?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.
/**
 * Included File: Dashboard Alerts
 *
 * Two "needs attention" nudges combined into one dashboard card,
 * deliberately invisible when there's nothing to flag, matching the
 * rest of the dashboard's "don't show it unless there's something to
 * act on" approach.
 *
 *   1. Quotes that have gone quiet: jobType 1, not completed/closed,
 *      with no edit in PAINTOPS_QUOTE_STALE_DAYS days. Contractors lose
 *      real business just by forgetting to circle back on a quote.
 *      Missing contact info gets a small inline note right on this row
 *      (instead of its own separate alerts section, which was overkill
 *      for something as small as a missing email) since a stale quote
 *      with no email/phone is exactly the case where Send Follow-Up
 *      Email/Text Follow-Up would otherwise fail with nothing to work
 *      with.
 *
 *   2. Completed jobs with no material expenses logged. Reuses
 *      paintops_job_financial_summary(), the same already-fixed function
 *      the Profits page and job reports use, so a job flagged here
 *      is judged by the exact same numbers that feed the profit-per-
 *      hour figures, not a separate one-off calculation.
 */

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

/** Days with no activity before an open quote gets flagged. */
define('PAINTOPS_QUOTE_STALE_DAYS', 7);

/** How far back to check completed jobs for missing expenses. */
define('PAINTOPS_EXPENSE_CHECK_DAYS', 60);

/**
 * Open quotes nobody has touched in a while.
 */
function paintops_get_stale_quotes(): array {
    global $wpdb;
    $jobs_table = paintops_table('jobs');
    $comm_table = paintops_table('comm_log');

    // A follow-up counts as activity (Mike 2026-07-29): sending the
    // follow-up email logs comm_type followup_email, and tapping Text
    // Follow-Up logs text_click through the app-wide data-po-comm
    // listener, but neither touches jobs.updated_on, so the quote used
    // to stay flagged right after the owner followed up. Any comm_log
    // entry for the job inside the stale window now clears it; if the
    // customer still goes quiet, the quote resurfaces once that
    // follow-up itself is older than the window.
    return $wpdb->get_results($wpdb->prepare(
        "SELECT j.* FROM %i j
         WHERE j.jobType = 1 AND j.completed != 1 AND j.not_won != 1
           AND j.updated_on <= DATE_SUB(NOW(), INTERVAL %d DAY)
           AND NOT EXISTS (
               SELECT 1 FROM %i c
               WHERE c.job_id = j.id
                 AND c.sent_at > DATE_SUB(NOW(), INTERVAL %d DAY)
           )
         ORDER BY j.updated_on ASC
         LIMIT 10", $jobs_table, PAINTOPS_QUOTE_STALE_DAYS, $comm_table, PAINTOPS_QUOTE_STALE_DAYS)) ?: [];
}

/**
 * Recently completed jobs with nothing logged in job_expenses, a
 * sign the profit numbers for that job are probably overstated.
 */
function paintops_get_jobs_missing_expenses(): array {
    // Follow-up visits (callback/punch_list/addon) are EXEMPT from this
    // warning (Mike 2026-07-23): punch lists and callbacks typically burn
    // leftover materials already on site, so "no materials logged" on
    // them is normal, not a gap, and by that stage the user knows how to
    // add a receipt when one actually exists. Jobs flagged
    // no_materials_needed (job form checkbox, same day: the customer
    // supplied everything) are exempt for the same reason.
    global $wpdb;

    if (!function_exists('paintops_job_financial_summary')) {
        return [];
    }

    $jobs_table = paintops_table('jobs');

    $candidates = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM %i
         WHERE jobType = 2 AND completed = 1
           AND COALESCE(visit_type, 'original') = 'original'
           AND no_materials_needed != 1
           AND updated_on >= DATE_SUB(NOW(), INTERVAL %d DAY)
         ORDER BY updated_on DESC
         LIMIT 50", $jobs_table, PAINTOPS_EXPENSE_CHECK_DAYS)) ?: [];

    $flagged = [];
    foreach ($candidates as $job) {
        $summary = paintops_job_financial_summary((int) $job->id);
        if ((float) ($summary['material_expenses'] ?? 0) <= 0) {
            $flagged[] = $job;
        }
    }

    return $flagged;
}

/**
 * Sent-but-unpaid invoices (2026-07-19, the receivables gap from the
 * pre-launch audit): every invoice that went to a customer and hasn't
 * been paid yet, oldest first, joined to its job for the name. "Sent"
 * is the meaningful state, drafts the owner hasn't sent yet aren't
 * receivables. Age is measured from sent_at (falling back to created_on
 * for rows sent before sent_at existed). Financials-gated by the caller,
 * same as the missing-expenses check.
 */
function paintops_get_unpaid_invoices(): array {
    global $wpdb;
    return $wpdb->get_results( $wpdb->prepare(
        "SELECT i.id, i.invoice_number, i.amount_cents, i.job_id,
                COALESCE(i.sent_at, i.created_on) AS owed_since,
                j.custFirst, j.custLast
         FROM %i i
         LEFT JOIN %i j ON j.id = i.job_id
         WHERE i.status = 'sent'
         ORDER BY owed_since ASC
         LIMIT 15", paintops_table('job_invoices'), paintops_table('jobs')) ) ?: [];
}

/**
 * Completed Jobs (jobType 2, completed) that still have money outstanding,
 * per the same canonical summary the board cards and the job-view balance
 * badge use. Free tier's home for collecting: each row renders with a
 * one-click "Mark as Paid" (Pro collects via invoices + the Payments log,
 * and has the Unpaid Invoices section above instead). Newest-first,
 * bounded: only the 40 most recent completed jobs are checked (the
 * summary is a per-job computation) and at most 15 render.
 */
function paintops_get_unpaid_completed_jobs(): array {
    global $wpdb;
    if (!function_exists('paintops_job_financial_summary')) {
        return [];
    }
    $rows = $wpdb->get_results( $wpdb->prepare(
        "SELECT id, jobTitle, jobCat, custFirst, custLast FROM %i WHERE jobType = 2 AND completed = 1 ORDER BY id DESC LIMIT 40", paintops_table('jobs')) ) ?: [];
    $out = [];
    foreach ($rows as $j) {
        $fin = paintops_job_financial_summary((int) $j->id);
        if ((float) ($fin['gross'] ?? 0) > 0 && (float) ($fin['remaining_balance'] ?? 0) > 0.005) {
            $j->po_balance = round((float) $fin['remaining_balance'], 2);
            $out[] = $j;
            if (count($out) >= 15) {
                break;
            }
        }
    }
    return $out;
}

function paintops_render_dashboard_alerts(): string {
    global $wpdb;

    // "Mark as Paid" POST from the unpaid-jobs section below (free tier;
    // moved here from the job view, Mike 2026-07-22, it sat randomly
    // there). Handled BEFORE the lists are fetched so the job drops out
    // of the section on the same page load. Writes the same job_payments
    // row the Pro Financials form would, so cards/Profits/an eventual
    // upgrade all see a normal payment.
    // "Archive" POST from the stale-quotes section (Mike 2026-07-29): the
    // owner knows a quote is dead (customer said no, went elsewhere) and
    // following up would be pointless. Semantically identical to the
    // board's "Mark as Not Won" (same not_won flag, same estimate
    // win-stamp clearing), so the quote leaves this list AND the board's
    // open Quotes column together, lands in Not Won, and stays counted
    // in win-rate stats. Reversible from the board's Not Won drawer.
    $archive_notice = '';
    if (isset($_POST['paintops_archive_quote'])
        && function_exists('paintops_user_can_manage') && paintops_user_can_manage()) {
        $aq_id = (int) ($_POST['job_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        if ($aq_id > 0 && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_archive_quote_nonce'] ?? '')), 'paintops_archive_quote_' . $aq_id)) {
            $wpdb->update(paintops_table('jobs'), ['not_won' => 1], ['id' => $aq_id, 'jobType' => 1], ['%d'], ['%d', '%d']);
            if (function_exists('paintops_clear_estimate_win_stamp')) {
                paintops_clear_estimate_win_stamp($aq_id);
            }
            $archive_notice = 'Quote archived as Not Won. Find it again in the job board\'s Not Won column.';
        }
    }

    $paid_notice = '';
    if (isset($_POST['paintops_mark_job_paid'])
        && function_exists('paintops_user_can_manage') && paintops_user_can_manage()
        && function_exists('paintops_job_financial_summary')) {
        $mp_id = (int) ($_POST['job_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        if ($mp_id > 0 && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_mark_paid_nonce'] ?? '')), 'paintops_mark_paid_' . $mp_id)) {
            $mp_fin = paintops_job_financial_summary($mp_id);
            $mp_remaining = round((float) ($mp_fin['remaining_balance'] ?? 0), 2);
            if ($mp_remaining > 0.005) {
                $wpdb->insert(paintops_table('job_payments'), [
                    'job_id' => $mp_id,
                    'payment_date' => current_time('Y-m-d'),
                    'amount' => $mp_remaining,
                    'method' => 'Marked as paid',
                    'created_by' => get_current_user_id(),
                ], ['%d', '%s', '%f', '%s', '%d']);
                $mp_cur = function_exists('paintops_currency_symbol') ? paintops_currency_symbol() : '$';
                $paid_notice = 'Marked as paid: ' . $mp_cur . number_format($mp_remaining, 2) . ' logged as received today.';
            }
        }
    }

    $stale_quotes = paintops_get_stale_quotes();
    // Missing-expenses is a financial-completeness check (see
    // paintops_get_jobs_missing_expenses() docblock), Administrator-only,
    // same as Profits and a job's own Financials box.
    $can_view_financials = function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials();
    $missing_expenses = $can_view_financials ? paintops_get_jobs_missing_expenses() : [];
    $unpaid_invoices = $can_view_financials ? paintops_get_unpaid_invoices() : [];
    // Free tier only: Pro collects through invoices (Unpaid Invoices
    // above) and the Financials Payments log, listing the same jobs
    // twice would be noise there.
    $is_free = !function_exists('paintops_pro_active') || !paintops_pro_active();
    $unpaid_jobs = ($can_view_financials && $is_free) ? paintops_get_unpaid_completed_jobs() : [];

    if (empty($stale_quotes) && empty($missing_expenses) && empty($unpaid_invoices) && empty($unpaid_jobs) && $paid_notice === '' && $archive_notice === '') {
        return '';
    }

    // Card CSS through the styles API (wp.org review 2026-07); was a
    // literal <style> block in the captured template below
    // (assets/css/dashboard-alerts.css).
    paintops_enqueue_style('dashboard-alerts');
    // Optimistic dismissal (Mike 2026-07-29): the server-side comm_log
    // exclusion above only takes effect on the NEXT page load, so fade
    // the row out immediately when a follow-up link in this card is
    // tapped. Email follow-up navigates away (compose screen), Text
    // stays on the page, so this mainly covers the sms: link.
    paintops_add_inline_script(
        "document.addEventListener('click', function (e) {" .
        "var a = e.target && e.target.closest ? e.target.closest('.paintops-alerts-card [data-po-comm]') : null;" .
        "if (!a) { return; }" .
        "var row = a.closest('.paintops-alert-row');" .
        "if (!row) { return; }" .
        "row.style.transition = 'opacity .6s ease';" .
        "setTimeout(function () { row.style.opacity = '0.35'; }, 150);" .
        "});"
    );

    ob_start();
    ?>
    <div class="paintops-alerts-card">
        <h3>Needs Your Attention</h3>

        <?php if ($paid_notice !== '') : ?>
            <div class="paintops-notice is-success" style="margin:0 0 10px;"><?php echo esc_html($paid_notice); ?></div>
        <?php endif; ?>

        <?php if ($archive_notice !== '') : ?>
            <div class="paintops-notice is-success" style="margin:0 0 10px;"><?php echo esc_html($archive_notice); ?></div>
        <?php endif; ?>

        <?php if (!empty($unpaid_jobs)) :
            $uj_currency = function_exists('paintops_currency_symbol') ? paintops_currency_symbol() : '$';
            $uj_can_manage = function_exists('paintops_user_can_manage') && paintops_user_can_manage();
        ?>
            <div class="paintops-alerts-section">
                <h4>Completed Jobs With a Balance Due</h4>
                <?php foreach ($unpaid_jobs as $uj) :
                    $uj_category = function_exists('paintops_get_category_details') ? (string) paintops_get_category_details((int) $uj->jobCat, 0) : '';
                    $uj_name = trim((string) $uj->custFirst . ' ' . (string) $uj->custLast);
                ?>
                    <div class="paintops-alert-row" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
                        <span>
                            <a href="<?php echo esc_url(paintops_admin_url(['viewEid' => (int) $uj->id])); ?>"><?php echo esc_html(trim($uj_category . ' ' . $uj->jobTitle)); ?></a>
                            <?php if ($uj_name !== '') : ?> &middot; <?php echo esc_html($uj_name); ?><?php endif; ?>
                            &middot; <strong class="paintops-alert-flag"><?php echo esc_html($uj_currency . number_format((float) $uj->po_balance, 2)); ?> due</strong>
                        </span>
                        <?php if ($uj_can_manage) : ?>
                        <form method="post" style="margin:0;" onsubmit="return confirm('Mark this job as paid? <?php echo esc_js($uj_currency . number_format((float) $uj->po_balance, 2)); ?> will be logged as received today.');">
                            <?php wp_nonce_field('paintops_mark_paid_' . (int) $uj->id, 'paintops_mark_paid_nonce'); ?>
                            <input type="hidden" name="job_id" value="<?php echo (int) $uj->id; ?>">
                            <button type="submit" name="paintops_mark_job_paid" value="1" class="paintops-btn paintops-btn-secondary" style="padding:2px 10px;font-size:12px;">Mark as Paid</button>
                        </form>
                        <?php endif; ?>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>

        <?php if (!empty($unpaid_invoices)) :
            $currency = function_exists('paintops_currency_symbol') ? paintops_currency_symbol() : '$';
            $owed_total = 0;
            foreach ($unpaid_invoices as $ui) { $owed_total += (int) $ui->amount_cents; }
        ?>
            <div class="paintops-alerts-section">
                <h4>Unpaid Invoices - <?php echo esc_html($currency . number_format($owed_total / 100, 2)); ?> outstanding</h4>
                <?php foreach ($unpaid_invoices as $ui) :
                    $days = max(0, (int) floor((current_time('timestamp') - strtotime((string) $ui->owed_since)) / DAY_IN_SECONDS));
                    $who = trim((string) $ui->custFirst . ' ' . (string) $ui->custLast);
                ?>
                    <div class="paintops-alert-row">
                        <a href="<?php echo esc_url(paintops_admin_url(['jobInvoice' => (int) $ui->id])); ?>"><?php echo esc_html($ui->invoice_number); ?></a>
                        &middot; <?php echo esc_html($who !== '' ? $who : 'Customer'); ?>
                        &middot; <strong><?php echo esc_html($currency . number_format((int) $ui->amount_cents / 100, 2)); ?></strong>
                        <span class="<?php echo esc_attr($days >= 14 ? 'paintops-alert-flag' : ''); ?>">&middot; sent <?php echo (int) $days; ?> day<?php echo esc_attr($days === 1 ? '' : 's'); ?> ago</span>
                        &middot; <a href="<?php echo esc_url(paintops_admin_url(['jobFinancials' => (int) $ui->job_id]) . '#paintops-payments'); ?>">Record Payment</a>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>

        <?php if (!empty($stale_quotes)) : ?>
            <div class="paintops-alerts-section">
                <h4>Quotes With No Follow-Up</h4>
                <?php foreach ($stale_quotes as $job) :
                    $category = function_exists('paintops_get_category_details') ? (string) paintops_get_category_details((int) $job->jobCat, 0) : '';
                    $custName = trim($job->custFirst . ' ' . $job->custLast);
                    $days = max(0, (int) floor((current_time('timestamp') - strtotime($job->updated_on)) / DAY_IN_SECONDS));

                    // Follow-Up actions, same editable template and sms:
                    // deep-link pattern used on the quote view page
                    // (estimates.php). Only shown once there's an actual
                    // quote to follow up on. Text Follow-Up additionally
                    // requires the quote be at least a day old and not yet
                    // accepted (matches the gating on the quote view page),
                    // in practice a stale quote here is already 7+ days old,
                    // but the not-accepted check still matters since this
                    // list doesn't otherwise exclude accepted-but-incomplete
                    // quotes.
                    $alert_estimate = function_exists('paintops_get_latest_estimate_for_job') ? paintops_get_latest_estimate_for_job((int) $job->id) : null;
                    $alert_followup_nonce = wp_create_nonce('paintops_send_followup_' . (int) $job->id);
                    $alert_followup_eligible = $alert_estimate
                        && empty($alert_estimate->accepted)
                        && !empty($alert_estimate->createdOn)
                        && strtotime($alert_estimate->createdOn) <= strtotime('-1 day', current_time('timestamp'));
                ?>
                    <div class="paintops-alert-row">
                        <a href="<?php echo esc_url(paintops_admin_url(['viewEid' => (int) $job->id])); ?>">
                            <?php echo esc_html(trim($category . ' ' . $job->jobTitle)); ?>
                        </a>
                        <?php if ($custName !== '') : ?> &middot; <?php echo esc_html($custName); ?><?php endif; ?>
                        <span class="paintops-alert-flag">(<?php echo esc_html((string) $days); ?> days, no update)</span>
                        <?php if ($alert_estimate && !empty($job->custEmail) && $alert_followup_eligible) : ?>
                            &middot; <a href="<?php echo paintops_href(['followupCompose' => (int) $job->id]); ?>">Send Follow-Up Email</a>
                            <?php if (!empty($job->custPhone) && function_exists('paintops_followup_template') && function_exists('paintops_render_followup_placeholders')) :
                                $alert_template = paintops_followup_template();
                                $alert_sms_text = paintops_render_followup_placeholders($alert_template['sms_body'], $job, $alert_estimate);
                                $alert_sms_phone = preg_replace('/[^0-9+]/', '', $job->custPhone);
                                $alert_sms_href = 'sms:' . $alert_sms_phone . '?&body=' . rawurlencode($alert_sms_text);
                            ?>
                                | <a href="<?php echo esc_attr($alert_sms_href); ?>" data-po-comm="text" data-po-job="<?php echo (int) $job->id; ?>" data-po-recipient="<?php echo esc_attr($alert_sms_phone); ?>" data-po-src="Needs Your Attention">Text Follow-Up</a>
                            <?php endif; ?>
                        <?php elseif ($alert_estimate) : ?>
                            &middot; <span class="paintops-alert-flag">no email on file</span> &middot; <a href="<?php echo esc_url(paintops_admin_url(['editEid' => (int) $job->id])); ?>">Add contact info</a>
                        <?php endif; ?>
                        <?php if (function_exists('paintops_user_can_manage') && paintops_user_can_manage()) : ?>
                            &middot; <form method="post" style="display:inline;margin:0;" onsubmit="return confirm('Archive this quote as Not Won? It leaves this list and moves to the job board\'s Not Won column (reversible there).');">
                                <?php wp_nonce_field('paintops_archive_quote_' . (int) $job->id, 'paintops_archive_quote_nonce'); ?>
                                <input type="hidden" name="job_id" value="<?php echo (int) $job->id; ?>">
                                <button type="submit" name="paintops_archive_quote" value="1" style="background:none;border:none;padding:0;margin:0;font:inherit;cursor:pointer;text-decoration:underline;color:var(--po-text-muted,#667581);" title="For a quote you know is dead: hides it here and marks it Not Won on the board.">Archive</button>
                            </form>
                        <?php endif; ?>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>

        <?php if (!empty($missing_expenses)) : ?>
            <div class="paintops-alerts-section">
                <h4>Completed Jobs Missing Expenses</h4>
                <?php foreach ($missing_expenses as $job) :
                    $category = function_exists('paintops_get_category_details') ? (string) paintops_get_category_details((int) $job->jobCat, 0) : '';
                    $custName = trim($job->custFirst . ' ' . $job->custLast);
                ?>
                    <div class="paintops-alert-row">
                        <?php echo esc_html(trim($category . ' ' . $job->jobTitle)); ?>
                        <?php if ($custName !== '') : ?> &middot; <?php echo esc_html($custName); ?><?php endif; ?>
                        <span class="paintops-alert-flag">(no materials logged)</span>
                        <?php // Routes to the Add Expense page (free feature),
                              // not the Pro Financials page, so free users can
                              // log expenses on old jobs while evaluating and
                              // have the data waiting when they upgrade
                              // (Mike 2026-07-22). ?>
                        &middot; <a href="<?php echo esc_url(paintops_admin_url(['jobAddReceipt' => (int) $job->id])); ?>">Add Expenses</a>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>
    </div>
    <?php
    return ob_get_clean();
}
