<?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.DateTime.RestrictedFunctions.date_date -- date() here is deliberately paired with current_time() timestamps: crews are scheduled on the site's local calendar day, and gmdate() would shift jobs across midnight UTC.
// 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.
/**
 * PaintOps - Job Invoices (Pro)
 *
 * A stored, numbered invoice for a job's remaining balance, created from the
 * Manage Financials page. Pass 1 (this file's current scope): create/list
 * invoices, choose which job photos + notes to include, and a printable /
 * PDF-able invoice document (?jobInvoice=<id>). Pass 2 adds the no-login
 * customer pay page (public_token), the Stripe "pay balance" button, the
 * webhook that records the payment, and emailing the invoice link.
 *
 * Numbering: "{CustomerName}-{jobID}-INV-{n}", a per-job sequence, so a job's
 * invoices run INV-1, INV-2, ... (Mike's requested format).
 *
 * Balance comes from paintops_job_financial_summary() (job-financials-addon.php) at
 * issue time and is snapshotted onto the row, so later payments/edits don't
 * silently change what a already-issued invoice says.
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Who can create/manage invoices: same gate as Job Financials (owner/admin),
 * since an invoice exposes the job's money figures.
 */
function paintops_user_can_manage_invoices() : bool {
    return function_exists( 'paintops_user_can_view_financials' ) && paintops_user_can_view_financials();
}

// end paintops_fs()->is__premium_only()
/** One invoice by id (or null). */
function paintops_get_invoice(  int $id  ) {
    global $wpdb;
    if ( $id <= 0 ) {
        return null;
    }
    return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'job_invoices' ), $id ) );
}

// end paintops_fs()->is__premium_only()
/**
 * The invoice document itself, business header, bill-to, job reference, the
 * totals (gross / paid / balance due), the optional message, and the chosen
 * photos + notes. Shared by the contractor print view (?jobInvoice=) and, in
 * pass 2, the public customer pay page. $public toggles the customer framing
 * (no internal actions), currently only the contractor view calls it.
 */
function paintops_render_invoice_document(  object $invoice, bool $public = false  ) : string {
    global $wpdb;
    $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $invoice->job_id ) );
    $company = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
    $currency = ( function_exists( 'paintops_currency_symbol' ) ? paintops_currency_symbol() : '$' );
    // Invoice-centric numbers: the invoice charges its own amount_cents (frozen
    // at issue). Once it's marked paid, that amount is settled and the balance
    // is zero, deliberately NOT mixing in the job's live payment total, which
    // drifts as other payments land and would leave a paid invoice still
    // showing a balance (the bug this replaced).
    $invoice_amount = (int) $invoice->amount_cents / 100;
    $is_paid = (string) $invoice->status === 'paid';
    $balance_due = ( $is_paid ? 0.0 : $invoice_amount );
    // Selected photos/notes.
    $photo_ids = array_filter( array_map( 'intval', explode( ',', (string) $invoice->photo_ids ) ) );
    $note_ids = array_filter( array_map( 'intval', explode( ',', (string) $invoice->note_ids ) ) );
    $sel_ids = array_values( array_unique( array_merge( $photo_ids, $note_ids ) ) );
    $rows_by_id = [];
    if ( $sel_ids && function_exists( 'paintops_get_job_feed' ) ) {
        foreach ( paintops_get_job_feed( (int) $invoice->job_id ) as $item ) {
            if ( in_array( (int) $item['id'], $sel_ids, true ) ) {
                $rows_by_id[(int) $item['id']] = $item;
            }
        }
    }
    $sel_photos = [];
    $sel_notes = [];
    foreach ( $photo_ids as $pid ) {
        if ( isset( $rows_by_id[$pid] ) && ($rows_by_id[$pid]['url'] ?? '') !== '' ) {
            $sel_photos[] = $rows_by_id[$pid];
        }
    }
    foreach ( $note_ids as $nid ) {
        if ( isset( $rows_by_id[$nid] ) && trim( (string) ($rows_by_id[$nid]['notes'] ?? '') ) !== '' ) {
            $sel_notes[] = $rows_by_id[$nid];
        }
    }
    $biz_name = ( trim( (string) ($company['name'] ?? '') ) !== '' ? (string) $company['name'] : get_bloginfo( 'name' ) );
    $addr = trim( trim( (string) ($company['address1'] ?? '') ) . "\n" . trim( (string) ($company['address2'] ?? '') ) );
    $cust_name = (string) $invoice->customer_name;
    $cust_addr = ( $job ? trim( ($job->custServiceAddress ?? '') . ', ' . ($job->custServiceCity ?? '') . ' ' . ($job->custServiceState ?? '') . ' ' . ($job->custServiceZip ?? '') ) : '' );
    $cust_addr = preg_replace( '/\\s+/', ' ', trim( $cust_addr, ', ' ) );
    ob_start();
    paintops_enqueue_style( 'invoice-doc' );
    ?>

    <div class="po-invoice" id="po-invoice-print">
        <?php 
    if ( !$public ) {
        $pub_url = paintops_invoice_public_url( $invoice );
        ?>
        <?php 
        // Action bar tidy-up (Mike 2026-07-27, "a lot going on"): the three
        // everyday actions stay visible; the occasional ones (copy link,
        // AI reminder) live behind the same More Actions toggle the job
        // and quote views use, and the customer pay link moved in with
        // them instead of dangling under the bar.
        $reminder_ai_ok = (string) $invoice->status !== 'paid' && function_exists( 'paintops_ai_ready' ) && paintops_ai_ready();
        $inv_more_id = 'po-inv-more-' . (int) $invoice->id;
        paintops_enqueue_script( 'po-toggles' );
        ?>
        <div class="po-inv-noprint po-inv-print-btn" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
            <button type="button" onclick="window.print();" class="paintops-btn paintops-btn-primary">Print / Save as PDF</button>
            <form method="post" style="margin:0;" onsubmit="return confirm('Email this invoice to the customer now? It sends immediately, using the standard invoice email with their secure pay link.');">
                <?php 
        wp_nonce_field( 'paintops_invoice_action_' . (int) $invoice->id, 'paintops_invoice_action_nonce' );
        ?>
                <button type="submit" name="paintops_send_invoice" value="1" class="paintops-btn paintops-btn-secondary">Email to customer</button>
            </form>
            <?php 
        if ( (string) $invoice->status !== 'paid' ) {
            ?>
            <form method="post" style="margin:0;" onsubmit="return confirm('Mark this invoice paid and log a Check / Cash payment for the full amount?');">
                <?php 
            wp_nonce_field( 'paintops_invoice_action_' . (int) $invoice->id, 'paintops_invoice_action_nonce' );
            ?>
                <button type="submit" name="paintops_mark_invoice_paid_manual" value="1" class="paintops-btn paintops-btn-secondary">Mark as Paid (check / cash)</button>
            </form>
            <?php 
        }
        ?>
            <?php 
        if ( $pub_url !== '' || $reminder_ai_ok ) {
            ?>
            <a href="#" class="pv-more-toggle" data-po-toggle="<?php 
            echo esc_attr( $inv_more_id );
            ?>" data-open-label="Hide More Actions" data-closed-label="More Actions"><span class="dashicons dashicons-admin-generic" aria-hidden="true"></span><span class="po-toggle-label">More Actions</span> <span class="pv-more-chevron">&#9662;</span></a>
            <?php 
        }
        ?>
        </div>
        <?php 
        if ( $pub_url !== '' || $reminder_ai_ok ) {
            ?>
        <div id="<?php 
            echo esc_attr( $inv_more_id );
            ?>" class="hidden-box pv-more-actions po-inv-noprint" style="margin:8px 0 0;">
            <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
                <?php 
            if ( $pub_url !== '' ) {
                ?>
                <button type="button" class="paintops-btn paintops-btn-secondary" onclick="navigator.clipboard&amp;&amp;navigator.clipboard.writeText('<?php 
                echo esc_js( $pub_url );
                ?>');this.textContent='Link copied';">Copy customer link</button>
                <?php 
            }
            ?>
                <?php 
            if ( $reminder_ai_ok ) {
                ?>
                <button type="button" class="paintops-btn paintops-btn-secondary" id="po-inv-reminder-btn">Draft a reminder with AI</button>
                <span id="po-inv-reminder-status" style="font-size:13px;"></span>
                <?php 
            }
            ?>
            </div>
            <?php 
            if ( $pub_url !== '' ) {
                ?>
            <p style="font-size:12px;color:var(--po-text-muted,#777);margin:8px 0 0;word-break:break-all;">Customer pay link: <a href="<?php 
                echo esc_url( $pub_url );
                ?>" target="_blank" rel="noopener noreferrer"><?php 
                echo esc_html( $pub_url );
                ?></a></p>
            <?php 
            }
            ?>
        </div>
        <?php 
        }
        ?>
        <?php 
        if ( !empty( $reminder_ai_ok ) ) {
            ?>
        <div id="po-inv-reminder-panel" class="po-inv-noprint" style="display:none;margin:0 0 16px;padding:12px 14px;border:1px solid var(--po-card-border,#ddd);border-radius:8px;background:var(--po-tile-bg,#f9fafb);">
            <p style="margin:0 0 8px;font-weight:600;">Draft reminder <span style="font-weight:400;color:var(--po-text-muted,#777);font-size:13px;">(review and edit, then hit Send Email, or use your own email/text app; nothing is sent until you do)</span></p>
            <label style="display:block;font-size:12px;font-weight:600;margin:6px 0 2px;">Email subject</label>
            <input type="text" id="po-inv-rem-subject" style="width:100%;box-sizing:border-box;padding:6px;border:1px solid var(--po-input-border,#ccc);border-radius:5px;">
            <label style="display:block;font-size:12px;font-weight:600;margin:8px 0 2px;">Email body</label>
            <textarea id="po-inv-rem-email" rows="7" style="width:100%;box-sizing:border-box;padding:6px;border:1px solid var(--po-input-border,#ccc);border-radius:5px;"></textarea>
            <label style="display:block;font-size:12px;font-weight:600;margin:8px 0 2px;">Text message (SMS)</label>
            <textarea id="po-inv-rem-sms" rows="3" style="width:100%;box-sizing:border-box;padding:6px;border:1px solid var(--po-input-border,#ccc);border-radius:5px;"></textarea>
            <p style="margin:10px 0 0;display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
                <button type="button" class="paintops-btn paintops-btn-primary" id="po-inv-rem-send">Send Email</button>
                <a href="#" id="po-inv-rem-mailto" class="button" target="_blank" rel="noopener noreferrer">Open in email app</a>
                <button type="button" class="button" id="po-inv-rem-copy-email">Copy email</button>
                <button type="button" class="button" id="po-inv-rem-copy-sms">Copy SMS</button>
                <span id="po-inv-rem-copy-status" style="font-size:12px;color:#1a7a43;"></span>
            </p>
        </div>
        <?php 
            // Admin/contractor view only ($public is false on this branch), so the
            // enqueue API is available; config rides along as an inline fragment.
            paintops_enqueue_script( 'job-invoice-reminder', 'var paintopsInvReminderCfg = ' . wp_json_encode( [
                'ajaxUrl'   => admin_url( 'admin-ajax.php' ),
                'nonce'     => wp_create_nonce( 'paintops_ai_invoice_reminder' ),
                'sendNonce' => wp_create_nonce( 'paintops_send_invoice_reminder' ),
                'invoiceId' => (int) $invoice->id,
            ] ) . ';' );
            ?>
        <?php 
        }
        ?>
        <?php 
    }
    ?>

        <div class="po-inv-head">
            <div class="po-inv-biz"><?php 
    if ( !empty( $company['logo'] ) ) {
        echo wp_kses( '<img src="' . esc_url( (string) $company['logo'] ) . '" alt="" style="max-height:60px;margin-bottom:8px;display:block;">', paintops_kses_allowed_html(), paintops_kses_protocols() );
    }
    echo wp_kses( '<div class="n">' . esc_html( $biz_name ) . '</div>', paintops_kses_allowed_html(), paintops_kses_protocols() );
    if ( $addr !== '' ) {
        echo esc_html( $addr ) . "\n";
    }
    if ( !empty( $company['phone'] ) ) {
        echo esc_html( (string) $company['phone'] ) . "\n";
    }
    if ( !empty( $company['email'] ) ) {
        echo esc_html( (string) $company['email'] );
    }
    ?></div>
            <div class="po-inv-meta">
                <div class="po-inv-title">INVOICE</div>
                <div><strong><?php 
    echo esc_html( (string) $invoice->invoice_number );
    ?></strong></div>
                <div>Date: <?php 
    echo esc_html( ( !empty( $invoice->created_on ) ? date( 'M j, Y', strtotime( (string) $invoice->created_on ) ) : gmdate( 'M j, Y', current_time( 'timestamp' ) ) ) );
    ?></div>
                <div>Status: <span style="text-transform:capitalize;"><?php 
    echo esc_html( (string) $invoice->status );
    ?></span></div>
            </div>
        </div>

        <div class="po-inv-parties">
            <div>
                <h4>Bill To</h4>
                <?php 
    echo esc_html( ( $cust_name !== '' ? $cust_name : 'Customer' ) );
    echo wp_kses( ( $cust_addr !== '' ? '<br>' . esc_html( $cust_addr ) : '' ), paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?>
                <?php 
    if ( $job && !empty( $job->custPhone ) ) {
        ?><br><?php 
        echo esc_html( (string) $job->custPhone );
    }
    ?>
                <?php 
    if ( $job && !empty( $job->custEmail ) ) {
        ?><br><?php 
        echo esc_html( (string) $job->custEmail );
    }
    ?>
            </div>
            <div style="text-align:right;">
                <h4>Job</h4>
                #<?php 
    echo (int) $invoice->job_id;
    echo wp_kses( ( $job && trim( (string) $job->jobTitle ) !== '' ? '<br>' . esc_html( (string) $job->jobTitle ) : '' ), paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?>
            </div>
        </div>

        <table class="po-inv-table">
            <thead>
                <tr><th>Description</th><th class="amt">Amount</th></tr>
            </thead>
            <tbody>
                <tr>
                    <td><?php 
    echo esc_html( ( $job && trim( (string) $job->jobTitle ) !== '' ? (string) $job->jobTitle : 'Painting services' ) );
    ?> (Job #<?php 
    echo (int) $invoice->job_id;
    ?>)</td>
                    <td class="amt"><?php 
    echo esc_html( $currency . number_format( $invoice_amount, 2 ) );
    ?></td>
                </tr>
                <?php 
    if ( $is_paid ) {
        ?>
                <tr>
                    <td>Paid<?php 
        echo esc_html( ( !empty( $invoice->paid_at ) ? ' on ' . date( 'M j, Y', strtotime( (string) $invoice->paid_at ) ) : '' ) );
        ?></td>
                    <td class="amt">&minus;<?php 
        echo esc_html( $currency . number_format( $invoice_amount, 2 ) );
        ?></td>
                </tr>
                <?php 
    }
    ?>
                <tr>
                    <td class="po-inv-balance">Balance Due</td>
                    <td class="amt po-inv-balance"><?php 
    echo esc_html( $currency . number_format( $balance_due, 2 ) );
    ?></td>
                </tr>
            </tbody>
        </table>

        <?php 
    if ( trim( (string) $invoice->message ) !== '' ) {
        ?>
        <div class="po-inv-msg"><?php 
        echo esc_html( (string) $invoice->message );
        ?></div>
        <?php 
    }
    ?>

        <?php 
    if ( $sel_photos ) {
        ?>
        <h4 style="margin:20px 0 0;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#777;">Photos</h4>
        <div class="po-inv-photos">
            <?php 
        foreach ( $sel_photos as $p ) {
            ?>
                <img src="<?php 
            echo esc_url( ( $p['full_url'] ?: $p['url'] ) );
            ?>" alt="Job photo">
            <?php 
        }
        ?>
        </div>
        <?php 
    }
    ?>

        <?php 
    if ( $sel_notes ) {
        ?>
        <h4 style="margin:20px 0 0;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#777;">Notes</h4>
        <ul class="po-inv-notes">
            <?php 
        foreach ( $sel_notes as $n ) {
            ?>
                <li><?php 
            echo esc_html( (string) $n['notes'] );
            ?> <span style="color:#999;">(<?php 
            echo esc_html( $n['uploaded_at_display'] );
            ?>)</span></li>
            <?php 
        }
        ?>
        </ul>
        <?php 
    }
    ?>
        <?php 
    if ( function_exists( 'paintops_powered_by_footer_html' ) ) {
        echo wp_kses( paintops_powered_by_footer_html(), paintops_kses_allowed_html(), paintops_kses_protocols() );
    }
    ?>
    </div>
    <?php 
    return ob_get_clean();
}

/* ============================================================
 * Pass 2: shareable pay link, Stripe payment, email delivery
 * ============================================================ */
/** Public, no-login URL for an invoice's customer pay page. */
function paintops_invoice_public_url(  object $invoice  ) : string {
    if ( empty( $invoice->public_token ) ) {
        return '';
    }
    return add_query_arg( [
        'paintops_invoice' => (int) $invoice->id,
        'token'            => (string) $invoice->public_token,
    ], home_url( '/' ) );
}

/**
 * Mark an invoice paid and log the payment into the job's Payments log so
 * Total Paid / Remaining Balance reflect it. Idempotent (Stripe retries the
 * webhook), guarded both by the status check and the session-id dedupe.
 */
function paintops_mark_invoice_paid(  int $invoice_id, string $session_id, string $method = 'Stripe'  ) : void {
    global $wpdb;
    $inv = paintops_get_invoice( $invoice_id );
    if ( !$inv || (string) $inv->status === 'paid' ) {
        return;
    }
    // Two callers since 2026-07-19 (pre-launch audit P0 #3): the Stripe
    // webhook (with a checkout session id) and the owner's manual
    // "Mark as Paid" button (empty session id, method 'Check / Cash').
    // Both flip the status AND log a row in the Job Financials Payments
    // log so Total Paid / Remaining Balance agree with the invoice, the
    // manual path used to not exist at all, leaving check-paid invoices
    // "sent" forever. Double-submit safety: the status flip above the
    // insert plus the early-return on already-paid means a second POST
    // can never double-log; the session-id dedupe below additionally
    // guards Stripe webhook replays.
    $update = [
        'status'  => 'paid',
        'paid_at' => current_time( 'mysql' ),
    ];
    $update_formats = ['%s', '%s'];
    if ( $session_id !== '' ) {
        $update['stripe_checkout_session_id'] = $session_id;
        $update_formats[] = '%s';
    }
    $wpdb->update(
        paintops_table( 'job_invoices' ),
        $update,
        [
            'id' => $invoice_id,
        ],
        $update_formats,
        ['%d']
    );
    $payments_table = paintops_table( 'job_payments' );
    $already = null;
    if ( $session_id !== '' ) {
        $already = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE stripe_checkout_session_id = %s LIMIT 1", $payments_table, $session_id ) );
    }
    if ( !$already ) {
        $wpdb->insert( $payments_table, [
            'job_id'                     => (int) $inv->job_id,
            'payment_date'               => current_time( 'Y-m-d' ),
            'amount'                     => (int) $inv->amount_cents / 100,
            'method'                     => $method,
            'stripe_checkout_session_id' => $session_id,
        ], [
            '%d',
            '%s',
            '%f',
            '%s',
            '%s'
        ] );
    }
}

// end paintops_fs()->is__premium_only()
/** Email the invoice's pay link to the customer. Returns true on success. */
function paintops_send_invoice_email(  object $invoice  ) : bool {
    global $wpdb;
    $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $invoice->job_id ) );
    if ( !$job || empty( $job->custEmail ) || !is_email( $job->custEmail ) ) {
        return false;
    }
    $company = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
    $biz = ( trim( (string) ($company['name'] ?? '') ) !== '' ? (string) $company['name'] : get_bloginfo( 'name' ) );
    $currency = ( function_exists( 'paintops_currency_symbol' ) ? paintops_currency_symbol() : '$' );
    $url = paintops_invoice_public_url( $invoice );
    $amount = $currency . number_format( (int) $invoice->amount_cents / 100, 2 );
    $cust = trim( (string) ($job->custFirst ?? '') );
    $subject = 'Invoice ' . $invoice->invoice_number . ' from ' . $biz;
    $body = 'Hi ' . (( $cust !== '' ? $cust : 'there' )) . ",\n\n" . "Here's your invoice from {$biz} for {$amount}.\n\n" . "You can view it and pay online here:\n{$url}\n\n" . (( trim( (string) $invoice->message ) !== '' ? $invoice->message . "\n\n" : '' )) . "Thank you!\n{$biz}";
    // Opt-in credit at the very bottom (plain text; shown only when the
    // admin enabled it, see paintops_show_powered_by()).
    if ( function_exists( 'paintops_powered_by_footer_text' ) ) {
        $paintops_pb = paintops_powered_by_footer_text();
        if ( $paintops_pb !== '' ) {
            $body .= "\n\n" . $paintops_pb;
        }
    }
    $headers = [];
    if ( !empty( $company['email'] ) && is_email( $company['email'] ) ) {
        $headers[] = 'Reply-To: ' . $company['email'];
    }
    $sent = wp_mail(
        $job->custEmail,
        $subject,
        $body,
        $headers
    );
    if ( $sent ) {
        // Communications log (2026-07-21): "Invoice emailed on <date>".
        if ( function_exists( 'paintops_log_communication' ) ) {
            paintops_log_communication( [
                'job_id'     => (int) $invoice->job_id,
                'invoice_id' => (int) $invoice->id,
                'comm_type'  => 'invoice_email',
                'recipient'  => (string) $job->custEmail,
                'subject'    => $subject,
            ] );
        }
        $wpdb->update(
            paintops_table( 'job_invoices' ),
            [
                'status'  => ( (string) $invoice->status === 'paid' ? 'paid' : 'sent' ),
                'sent_at' => current_time( 'mysql' ),
            ],
            [
                'id' => (int) $invoice->id,
            ],
            ['%s', '%s'],
            ['%d']
        );
    }
    return (bool) $sent;
}

/**
 * Owner action handler for the ?jobInvoice= page (email the invoice). Returns
 * a notice HTML string. Called before the document renders so the status
 * reflects the action.
 */
function paintops_process_invoice_owner_action(  object $invoice  ) : string {
    if ( !paintops_user_can_manage_invoices() ) {
        return '';
    }
    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- presence check only, compared against fixed values, the raw value is never output or stored
    if ( isset( $_POST['paintops_send_invoice'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_invoice_action_nonce'] ?? '' ) ), 'paintops_invoice_action_' . (int) $invoice->id ) ) {
        if ( paintops_send_invoice_email( $invoice ) ) {
            return '<div class="paintops-notice is-success">Invoice emailed to the customer.</div>';
        }
        return '<div class="paintops-notice is-error">Could not email the invoice. Check that the customer has a valid email address on the job.</div>';
    }
    // Manual "Mark as Paid" for check/cash payments (2026-07-19, audit
    // P0 #3). Logs the payment too, so the job's balance and Paid pill
    // agree without a separate Financials entry.
    if ( isset( $_POST['paintops_mark_invoice_paid_manual'] ) && (string) $invoice->status !== 'paid' && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_invoice_action_nonce'] ?? '' ) ), 'paintops_invoice_action_' . (int) $invoice->id ) ) {
        paintops_mark_invoice_paid( (int) $invoice->id, '', 'Check / Cash' );
        return '<div class="paintops-notice is-success">Invoice marked paid and the payment was logged to Job Financials.</div>';
    }
    return '';
}

/**
 * In-app send for the AI-drafted payment reminder (Mike 2026-07-27):
 * same prepare-review-submit shape as sending a quote, so the admin
 * edits the draft on screen and one click emails it, instead of hopping
 * out to a mail app. The recipient is always re-derived server-side from
 * the invoice's job record; the client only supplies subject and body.
 */
function paintops_ajax_send_invoice_reminder() : void {
    // Nonce first, standalone, before ANY other request value is read.
    // check_ajax_referer() dies on failure, nothing below runs without it.
    check_ajax_referer( 'paintops_send_invoice_reminder', 'nonce' );
    if ( !function_exists( 'paintops_user_can_manage_invoices' ) || !paintops_user_can_manage_invoices() ) {
        wp_send_json_error( [
            'message' => 'You do not have permission to send invoice reminders.',
        ] );
    }
    $invoice_id = ( isset( $_POST['invoice_id'] ) ? absint( wp_unslash( $_POST['invoice_id'] ) ) : 0 );
    if ( $invoice_id <= 0 ) {
        wp_send_json_error( [
            'message' => 'Invoice not found.',
        ] );
    }
    $invoice = paintops_get_invoice( $invoice_id );
    if ( !$invoice ) {
        wp_send_json_error( [
            'message' => 'Invoice not found.',
        ] );
    }
    global $wpdb;
    $rem_job = $wpdb->get_row( $wpdb->prepare( "SELECT custEmail, custFirst, custLast FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $invoice->job_id ) );
    $to = ( $rem_job ? sanitize_email( (string) $rem_job->custEmail ) : '' );
    if ( $to === '' || !is_email( $to ) ) {
        wp_send_json_error( [
            'message' => 'No valid customer email on this job. Add one on the job form first.',
        ] );
    }
    $subject = sanitize_text_field( wp_unslash( $_POST['subject'] ?? '' ) );
    $body = sanitize_textarea_field( wp_unslash( $_POST['body'] ?? '' ) );
    if ( $subject === '' || trim( $body ) === '' ) {
        wp_send_json_error( [
            'message' => 'Add a subject and a message before sending.',
        ] );
    }
    $sent = wp_mail( $to, $subject, $body );
    if ( !$sent ) {
        wp_send_json_error( [
            'message' => 'The email could not be sent. Your host\'s mail function refused it, try Open in email app instead.',
        ] );
    }
    if ( function_exists( 'paintops_log_communication' ) ) {
        paintops_log_communication( [
            'job_id'     => (int) $invoice->job_id,
            'invoice_id' => (int) $invoice->id,
            'comm_type'  => 'reminder_email',
            'recipient'  => $to,
            'subject'    => $subject,
        ] );
    }
    wp_send_json_success( [
        'message' => 'Reminder sent to ' . $to . '.',
    ] );
}

add_action( 'wp_ajax_paintops_send_invoice_reminder', 'paintops_ajax_send_invoice_reminder' );
/**
 * Public customer invoice page (no login, token-gated), and the Stripe
 * "pay online" redirect. Hooked on init like the public quote page so it
 * renders a bare standalone page and exits before the theme loads.
 */
function paintops_handle_public_invoice_request() : void {
    if ( !isset( $_GET['paintops_invoice'] ) ) {
        return;
    }
    $inv_id = (int) $_GET['paintops_invoice'];
    $token = ( isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['token'] ) ) : '' );
    $invoice = paintops_get_invoice( $inv_id );
    $valid = $invoice && !empty( $invoice->public_token ) && $token !== '' && hash_equals( (string) $invoice->public_token, $token );
    if ( !$valid ) {
        paintops_render_invoice_shell( '<h1>Link not found</h1><p>This invoice link isn&#39;t valid. Please contact us and we&#39;ll send you a fresh one.</p>' );
        exit;
    }
    // (2026-07-19, audit polish) An invoice shared via "Copy customer
    // link" (never emailed) used to sit in 'draft' forever, invisible to
    // the Unpaid Invoices receivables alert. The customer opening the
    // link IS the send: promote draft -> sent on first open, stamping
    // sent_at so the alert's age is when they first saw it.
    if ( (string) $invoice->status === 'draft' ) {
        global $wpdb;
        $wpdb->update(
            paintops_table( 'job_invoices' ),
            [
                'status'  => 'sent',
                'sent_at' => current_time( 'mysql' ),
            ],
            [
                'id' => (int) $invoice->id,
            ],
            ['%s', '%s'],
            ['%d']
        );
        $invoice = paintops_get_invoice( $inv_id );
    }
    // Pay action -> Stripe Checkout for the full invoice amount.
    if ( isset( $_GET['pay'] ) && (string) $invoice->status !== 'paid' ) {
        $can_pay = function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready() && function_exists( 'paintops_create_stripe_checkout_session' ) && (int) $invoice->amount_cents > 0;
        if ( $can_pay ) {
            global $wpdb;
            $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $invoice->job_id ) );
            $company = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
            $biz = ( trim( (string) ($company['name'] ?? '') ) !== '' ? (string) $company['name'] : get_bloginfo( 'name' ) );
            $url = paintops_invoice_public_url( $invoice );
            $session = paintops_create_stripe_checkout_session( [
                'estimate_id'    => 0,
                'invoice_id'     => (int) $invoice->id,
                'job_id'         => (int) $invoice->job_id,
                'amount_cents'   => (int) $invoice->amount_cents,
                'description'    => 'Invoice ' . $invoice->invoice_number . ' - ' . $biz,
                'customer_email' => ( $job ? (string) $job->custEmail : '' ),
                'success_url'    => add_query_arg( 'paid', '1', $url ),
                'cancel_url'     => add_query_arg( 'cancelled', '1', $url ),
            ] );
            if ( !is_wp_error( $session ) ) {
                $wpdb->update(
                    paintops_table( 'job_invoices' ),
                    [
                        'stripe_checkout_session_id' => $session['id'],
                    ],
                    [
                        'id' => (int) $invoice->id,
                    ],
                    ['%s'],
                    ['%d']
                );
                // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- destination is the Stripe-hosted checkout URL just returned by Stripe's API; wp_safe_redirect() would refuse the external host.
                wp_redirect( esc_url_raw( $session['url'] ) );
                exit;
            }
            paintops_render_invoice_shell( '<h1>Payment could not start</h1><p>' . esc_html( $session->get_error_message() ) . '</p><p>Please contact us and we\'ll help arrange payment.</p>' );
            exit;
        }
    }
    // (2026-07-19, audit P1) "Paid" is claimed ONLY from the webhook-set
    // status, never from the spoofable ?paid=1 return flag. Returning from
    // Stripe before the webhook lands shows a neutral "finalizing" state
    // instead: no paid claim, no pay button (double-payment guard), and a
    // 5s auto-reload (capped) until the status flips for real.
    $really_paid = (string) $invoice->status === 'paid';
    $processing = !$really_paid && isset( $_GET['paid'] );
    $paid = $really_paid || $processing;
    // suppresses the pay button below
    $doc = paintops_render_invoice_document( $invoice, true );
    $banner = '';
    if ( $really_paid ) {
        $banner = '<div style="margin:0 0 18px;padding:14px;background:#dff0d8;color:#3c763d;border-radius:8px;font-weight:600;text-align:center;">This invoice has been paid. Thank you!</div>';
    } elseif ( $processing ) {
        // data-po-poll drives assets/js/customer-poll.js (printed by the
        // shell through the enqueue API): reload up to 12 times, 5s
        // apart, until the webhook-set paid state lands.
        $banner = '<div data-po-poll="po_inv_poll" style="margin:0 0 18px;padding:14px;background:#f3f9fd;color:#31708f;border-radius:8px;font-weight:600;text-align:center;">Finishing up your payment&hellip; this page will update by itself in a few seconds. If it doesn\'t, don\'t pay again, just contact us and we\'ll confirm it went through.</div>';
    } elseif ( isset( $_GET['cancelled'] ) ) {
        $banner = '<div style="margin:0 0 18px;padding:12px;background:#fcf3d2;color:#8a6d3b;border-radius:8px;text-align:center;">Payment was cancelled. You can try again below whenever you\'re ready.</div>';
    }
    // (poll counters are cleared automatically by customer-poll.js when
    // no data-po-poll element is on the page)
    $pay_html = '';
    if ( !$paid ) {
        $can_pay = function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready() && (int) $invoice->amount_cents > 0;
        if ( $can_pay ) {
            $currency = ( function_exists( 'paintops_currency_symbol' ) ? paintops_currency_symbol() : '$' );
            $amount = $currency . number_format( (int) $invoice->amount_cents / 100, 2 );
            $pay_url = esc_url( add_query_arg( 'pay', '1', paintops_invoice_public_url( $invoice ) ) );
            $pay_html = '<div style="text-align:center;margin:22px 0;"><a href="' . $pay_url . '" style="display:inline-block;background:#2f8f4e;color:#fff;font-weight:700;font-size:17px;padding:14px 30px;border-radius:8px;text-decoration:none;">Pay ' . esc_html( $amount ) . ' online</a><p style="font-size:12px;color:#777;margin:8px 0 0;">Secure payment powered by Stripe.</p></div>';
        }
    }
    paintops_render_invoice_shell( $banner . $pay_html . $doc, true );
    exit;
}

add_action( 'init', 'paintops_handle_public_invoice_request' );
/** Bare standalone HTML wrapper for the public invoice page. */
function paintops_render_invoice_shell(  string $body_html, bool $wide = false  ) : void {
    ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invoice</title>
<?php 
    // Both stylesheets through the enqueue API: register, then print the
    // link tags right here in this standalone document's head.
    paintops_customer_doc_styles();
    wp_register_style(
        'paintops-invoice-doc',
        plugins_url( 'assets/css/invoice-doc.css', PAINTOPS_PLUGIN_FILE ),
        [],
        PAINTOPS_VERSION
    );
    wp_print_styles( ['paintops-invoice-doc'] );
    ?>
</head>
<body class="po-doc po-inv-shell-body">
<div class="paintops-invoice-shell<?php 
    echo esc_attr( ( $wide ? ' po-inv-wide' : '' ) );
    ?>"><?php 
    echo wp_kses( $body_html, paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?></div>
<?php 
    paintops_customer_doc_scripts();
    ?>
</body>
</html>
    <?php 
}
