<?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.
/**
 * Included File: Customer Payments
 *
 * Two things live here, tightly coupled so they stay in one file:
 *
 * 1. A public, no-login "view your quote and accept it" page. Before this
 *    file existed, a customer's only way to accept a quote was to reply
 *    to an email with the words "I accept this quote", there was no
 *    online acceptance mechanism at all. Access is gated by an
 *    unguessable per-estimate token (public_token on cs_estimates), never
 *    by a WordPress login, since customers don't have WP accounts.
 *
 * 2. Stripe Checkout integration for collecting a job's deposit online,
 *    called via plain wp_remote_post() against Stripe's REST API,
 *    deliberately not the stripe-php Composer SDK, to avoid vendoring a
 *    large dependency tree the way Freemius's SDK already is.
 *
 * IMPORTANT: a job's payment_plan (see paintops_payment_plan_options() in
 * paintops.php) is checked before Stripe is ever contacted. Only
 * 'deposit_online' jobs, on a Pro site with Stripe keys configured, ever
 * generate a Stripe API call. 'deposit_offline' (cash/check) and
 * 'no_deposit' (trusted repeat customer) jobs use the plain accept path
 * below with no payment step at all.
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Get (or lazily generate) the unguessable token used in a customer's
 * quote link. Generated once per estimate and stored, not derived from
 * anything guessable like the estimate ID.
 */
function paintops_estimate_public_token(  int $est_id  ) : string {
    global $wpdb;
    $table = paintops_table( 'estimates' );
    $token = $wpdb->get_var( $wpdb->prepare( "SELECT public_token FROM %i WHERE estID = %d LIMIT 1", $table, $est_id ) );
    if ( !empty( $token ) ) {
        return (string) $token;
    }
    $token = wp_generate_password( 40, false, false );
    $wpdb->update(
        $table,
        [
            'public_token' => $token,
        ],
        [
            'estID' => $est_id,
        ],
        ['%s'],
        ['%d']
    );
    return $token;
}

/**
 * The link to send a customer so they can view and accept a quote.
 */
function paintops_customer_quote_url(  int $est_id  ) : string {
    $token = paintops_estimate_public_token( $est_id );
    if ( $token === '' ) {
        return '';
    }
    return add_query_arg( [
        'paintops_view_quote' => $est_id,
        'token'               => $token,
    ], home_url( '/' ) );
}

/**
 * The link to the full itemized quote (line items, work description, "our
 * standard" text), the same token as the accept page, just a different
 * query param, so no separate access control needed. Kept as a distinct
 * page rather than folded into the accept page itself so the accept page
 * stays a fast, simple total + Accept button on a phone, with the detail
 * one tap away for anyone who wants it.
 */
function paintops_customer_quote_full_url(  int $est_id  ) : string {
    $token = paintops_estimate_public_token( $est_id );
    if ( $token === '' ) {
        return '';
    }
    return add_query_arg( [
        'paintops_view_quote_full' => $est_id,
        'token'                    => $token,
    ], home_url( '/' ) );
}

/**
 * Most recent quote/estimate row for a job, or null if the job has none
 * yet. Used by the Send Follow-Up actions (dashboard alerts + quote view
 * page) to find which estimate to link/text, a job can be re-quoted,
 * so this always follows the newest one, same ORDER BY estID DESC
 * pattern used elsewhere (job-view.php, job-financials-addon.php).
 */
function paintops_get_latest_estimate_for_job(  int $jobID  ) {
    global $wpdb;
    $table = paintops_table( 'estimates' );
    return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE jobID = %d ORDER BY estID DESC LIMIT 1", $table, $jobID ) );
}

/**
 * Sends the Follow-Up email (from the editable template in Business
 * Settings) for a given job/estimate. Returns true on success so callers
 * can show the right notice.
 */
function paintops_send_followup_email(  $job, $estimate  ) : bool {
    if ( empty( $job->custEmail ) || !is_email( $job->custEmail ) ) {
        return false;
    }
    $template = paintops_followup_template();
    $subject = paintops_render_followup_placeholders( $template['email_subject'], $job, $estimate );
    $body = paintops_render_followup_placeholders( $template['email_body'], $job, $estimate );
    return (bool) wp_mail(
        $job->custEmail,
        $subject,
        $body,
        paintops_mail_headers()
    );
}

/**
 * Front controller for the public quote page. Hooked early (init) so it
 * can render a bare-bones standalone page and exit before the active
 * theme loads, this page is never meant to require a WP login or look
 * like the rest of the site, just like the plugin's own PDF/print views.
 */
function paintops_handle_public_quote_request() : void {
    if ( !isset( $_GET['paintops_view_quote'] ) ) {
        return;
    }
    // No nonce is possible here BY DESIGN: this is the public customer
    // quote link (no WP login, no session to bind a nonce to). Access is
    // authenticated instead by the hash_equals() comparison a few lines
    // below against the estimate's unguessable per-record token; both
    // values read here feed ONLY that check until it passes.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $est_id = (int) $_GET['paintops_view_quote'];
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $token = ( isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['token'] ) ) : '' );
    global $wpdb;
    $estimate = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE estID = %d LIMIT 1", paintops_table( 'estimates' ), $est_id ) );
    $valid = $estimate && !empty( $estimate->public_token ) && $token !== '' && hash_equals( (string) $estimate->public_token, $token );
    if ( !$valid ) {
        // Deliberately generic, don't reveal whether the estimate ID
        // exists at all to someone guessing tokens.
        paintops_render_customer_quote_shell( '<h1>Link not found</h1><p>This quote link isn&#39;t valid. Please contact us and we&#39;ll send you a fresh one.</p>' );
        exit;
    }
    $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $estimate->jobID ) );
    if ( !$job ) {
        paintops_render_customer_quote_shell( '<h1>Quote not found</h1><p>Please contact us directly and we\'ll help sort this out.</p>' );
        exit;
    }
    // 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_accept_quote'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_accept_nonce'] ?? '' ) ), 'paintops_accept_quote_' . $est_id ) ) {
        if ( function_exists( 'paintops_quote_is_expired' ) && paintops_quote_is_expired( $estimate ) ) {
            paintops_render_customer_quote_shell( '<h1>This quote has expired</h1><p>Prices can change over time, so this quote can no longer be accepted online. Please reply to the email it came in, or give us a call, and we\'ll be happy to refresh it for you.</p>' );
            exit;
        }
        paintops_process_quote_accept( $estimate, $job );
        exit;
    }
    // Decline flow (2026-07-19, v1.0.105, Mike's pick: decline with
    // re-accept allowed). Records the date + optional reason, emails the
    // owner, thanks the customer. The link keeps working, a customer who
    // changes their mind can still accept, and accepting clears the
    // decline (see paintops_process_quote_accept).
    if ( isset( $_POST['paintops_decline_quote'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_accept_nonce'] ?? '' ) ), 'paintops_accept_quote_' . $est_id ) ) {
        $decline_reason = sanitize_textarea_field( wp_unslash( $_POST['paintops_decline_reason'] ?? '' ) );
        $wpdb->update(
            paintops_table( 'estimates' ),
            [
                'declined_date'   => current_time( 'Y-m-d' ),
                'declined_reason' => $decline_reason,
            ],
            [
                'estID' => $est_id,
            ],
            ['%s', '%s'],
            ['%d']
        );
        paintops_notify_owner_quote_declined( $job, $estimate, $decline_reason );
        paintops_render_customer_quote_shell( '<h1>Thanks for letting us know</h1><p>We appreciate you taking the time to tell us. If anything changes, or you\'d like to talk through the price or scope, just reply to the email we sent or give us a call, this link will keep working if you change your mind.</p>' );
        exit;
    }
    paintops_mark_quote_viewed( $estimate, $job );
    paintops_render_customer_quote_page( $estimate, $job );
    exit;
}

add_action( 'init', 'paintops_handle_public_quote_request' );
/**
 * Record that a customer actually opened their quote link, the same
 * "someone just looked at this" signal competitors like PaintBox sell as
 * a feature. Only fires from a real page load (this function isn't
 * called from the Accept POST handler above, or from the full-itemized
 * detail page, which is a secondary deep-dive rather than the main
 * "opened the quote" moment).
 *
 * viewed_at is set once, on the first open, and never touched again,
 * that's what the owner notification below keys off of, so reopening
 * the same link doesn't re-notify every time. view_count keeps
 * incrementing on every open after that, in case it's ever useful to
 * show "opened 3 times" instead of just a yes/no.
 */
function paintops_mark_quote_viewed(  $estimate, $job  ) : void {
    global $wpdb;
    $table = paintops_table( 'estimates' );
    $is_first_view = empty( $estimate->viewed_at );
    if ( $is_first_view ) {
        $wpdb->query( $wpdb->prepare(
            "UPDATE %i SET view_count = view_count + 1, viewed_at = %s WHERE estID = %d",
            $table,
            current_time( 'mysql' ),
            (int) $estimate->estID
        ) );
    } else {
        $wpdb->query( $wpdb->prepare( "UPDATE %i SET view_count = view_count + 1 WHERE estID = %d", $table, (int) $estimate->estID ) );
    }
    if ( $is_first_view && function_exists( 'paintops_notify_owner_quote_viewed' ) ) {
        paintops_notify_owner_quote_viewed( $job, $estimate );
    }
}

/**
 * Front controller for the full itemized quote page, same token/access
 * check as the accept page above, just a different query param and a
 * different renderer. Read-only: no Accept action lives here, so there's
 * no POST handling to worry about.
 */
function paintops_handle_public_quote_full_request() : void {
    if ( !isset( $_GET['paintops_view_quote_full'] ) ) {
        return;
    }
    $est_id = (int) $_GET['paintops_view_quote_full'];
    $token = ( isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['token'] ) ) : '' );
    global $wpdb;
    $estimate = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE estID = %d LIMIT 1", paintops_table( 'estimates' ), $est_id ) );
    $valid = $estimate && !empty( $estimate->public_token ) && $token !== '' && hash_equals( (string) $estimate->public_token, $token );
    if ( !$valid ) {
        paintops_render_customer_quote_shell( '<h1>Link not found</h1><p>This quote link isn&#39;t valid. Please contact us and we&#39;ll send you a fresh one.</p>' );
        exit;
    }
    $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), (int) $estimate->jobID ) );
    if ( !$job ) {
        paintops_render_customer_quote_shell( '<h1>Quote not found</h1><p>Please contact us directly and we\'ll help sort this out.</p>' );
        exit;
    }
    paintops_render_public_quote_details( $estimate, $job );
    exit;
}

add_action( 'init', 'paintops_handle_public_quote_full_request' );
/**
 * Handle a customer clicking "Accept" on their quote page. For an
 * online-deposit job (Pro + Stripe configured + a deposit is actually
 * owed), this redirects to Stripe Checkout instead of accepting
 * immediately, acceptance is only finalized once the webhook confirms
 * payment, so a customer who abandons checkout doesn't end up marked
 * "accepted" without having paid anything.
 */
function paintops_process_quote_accept(  $estimate, $job  ) : void {
    // A customer can only be held to exclusions they actually saw. The
    // acknowledgment checkbox below (see paintops_render_customer_quote_page())
    // is a required field, so a normal browser already stops submission
    // without it, this is the server-side half of that same check, for
    // the same reason every other form in this plugin re-validates
    // server-side instead of trusting the client. Only enforced when
    // there are actual Exclusions on this estimate to review; a quote
    // with nothing entered there adds no extra step.
    $exclusions = trim( stripslashes( (string) ($estimate->scope_notes ?? '') ) );
    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the accept flow is gated by the paintops_accept_quote nonce verified at the top of this handler
    if ( $exclusions !== '' && empty( $_POST['paintops_exclusions_ack'] ) ) {
        paintops_render_customer_quote_page( $estimate, $job, 'Please check the box confirming you\'ve reviewed the exclusions before accepting.' );
        return;
    }
    // Typed-name e-signature (2026-07-19, audit P1): required, server-side
    // half of the required input on the form. Stored below with a full
    // timestamp and the accepting IP so the acceptance record can stand up
    // to "I never agreed to that" (the old record was a bare date).
    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the accept flow is gated by the paintops_accept_quote nonce verified at the top of this handler
    $accept_name = sanitize_text_field( wp_unslash( $_POST['paintops_accept_name'] ?? '' ) );
    if ( $accept_name === '' ) {
        paintops_render_customer_quote_page( $estimate, $job, 'Please type your full name to accept, it acts as your signature.' );
        return;
    }
    $accept_ip = sanitize_text_field( wp_unslash( (string) ($_SERVER['REMOTE_ADDR'] ?? '') ) );
    $accept_at = current_time( 'mysql' );
    // Recorded immediately, on BOTH paths: for an online deposit the
    // accepted flag itself still flips only in the verified webhook, but
    // who signed, when, and from where is a fact of this POST, capturing
    // it now means the record exists even if the customer abandons the
    // Stripe page after signing.
    global $wpdb;
    $wpdb->update(
        paintops_table( 'estimates' ),
        [
            'accepted_name'   => $accept_name,
            'accepted_at'     => $accept_at,
            'accepted_ip'     => $accept_ip,
            'declined_date'   => null,
            'declined_reason' => null,
        ],
        [
            'estID' => (int) $estimate->estID,
        ],
        [
            '%s',
            '%s',
            '%s',
            '%s',
            '%s'
        ],
        ['%d']
    );
    $payment_plan = paintops_sanitize_payment_plan( (string) ($job->payment_plan ?? 'deposit_online') );
    $deposit_info = paintops_calculate_deposit( (float) $estimate->estTotal, $payment_plan );
    $can_pay_online = $payment_plan === 'deposit_online' && function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready() && function_exists( 'paintops_create_stripe_checkout_session' ) && $deposit_info['requires_deposit'];
    if ( $can_pay_online ) {
        $quote_url = paintops_customer_quote_url( (int) $estimate->estID );
        $success_url = add_query_arg( 'paid', '1', $quote_url );
        $cancel_url = add_query_arg( 'cancelled', '1', $quote_url );
        $company = paintops_company_profile();
        $description = ( $company['name'] !== '' ? "Deposit – {$company['name']}" : 'Job Deposit' );
        $session = paintops_create_stripe_checkout_session( [
            'estimate_id'    => (int) $estimate->estID,
            'job_id'         => (int) $job->id,
            'amount_cents'   => (int) round( $deposit_info['amount'] * 100 ),
            'description'    => $description,
            'customer_email' => (string) $job->custEmail,
            'success_url'    => $success_url,
            'cancel_url'     => $cancel_url,
        ] );
        if ( is_wp_error( $session ) ) {
            paintops_render_customer_quote_page( $estimate, $job, 'We could not start the payment process (' . $session->get_error_message() . '). Please contact us directly to arrange payment.' );
            return;
        }
        // Record the session id before sending the customer to Stripe, so
        // the webhook can be matched back to this estimate even if the
        // customer closes the tab instead of returning to success_url.
        global $wpdb;
        $wpdb->update(
            paintops_table( 'estimates' ),
            [
                'stripe_checkout_session_id' => $session['id'],
            ],
            [
                'estID' => (int) $estimate->estID,
            ],
            ['%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;
    }
    // Offline deposit, no deposit required, or online payment isn't
    // available on this site, record acceptance directly, no payment
    // involved.
    global $wpdb;
    $wpdb->update(
        paintops_table( 'estimates' ),
        [
            'accepted'      => 1,
            'accepted_date' => current_time( 'Y-m-d' ),
        ],
        [
            'estID' => (int) $estimate->estID,
        ],
        ['%d', '%s'],
        ['%d']
    );
    // Acceptance = won, on EVERY path (2026-07-19, pre-launch audit P0 #1,
    // Mike's call): promote the quote to an active Job here too, exactly
    // like the Stripe-deposit webhook (paintops_mark_deposit_paid) already
    // does. Before this fix, a pay-by-check / no-deposit / Stripe-less
    // acceptance left the record a "Quote" forever with no state change.
    // not_won is cleared in case a previously written-off lead accepts
    // from an old link (mirrors the Job Board's "Mark as Job" move).
    if ( !empty( $estimate->jobID ) ) {
        $wpdb->update(
            paintops_table( 'jobs' ),
            [
                'jobType' => 2,
                'not_won' => 0,
            ],
            [
                'id' => (int) $estimate->jobID,
            ],
            ['%d', '%d'],
            ['%d']
        );
    }
    paintops_notify_owner_quote_accepted( $job, $estimate );
    paintops_render_customer_quote_shell( '<h1>Thank you!</h1><p>Your quote has been accepted. We\'ll be in touch shortly to schedule the work.</p>' );
}

/**
 * The customer-facing quote page: totals, deposit terms, and an Accept
 * button. A fully standalone HTML document (no WP theme/header/footer)
 * since the person viewing it has no WP account and shouldn't need one.
 */
function paintops_render_customer_quote_page(  $estimate, $job, string $error_message = ''  ) : void {
    $company = paintops_company_profile();
    $categories = paintops_job_categories();
    $cat_name = $categories[(int) $job->jobCat]['name'] ?? 'Quote';
    // Same Quote/Estimate/Bid wording as the full quote page (see
    // paintops_render_public_quote_details()). isBid comes from the JOB
    // (job-level fact, follows a Bid -> Estimate change immediately);
    // the estimate row's snapshot is only a fallback.
    $quote_word = ( (int) ($job->isBid ?? $estimate->isBid ?? 0) === 1 ? 'Bid' : (( (string) ($job->quote_label ?? 'quote') === 'estimate' ? 'Estimate' : 'Quote' )) );
    $payment_plan = paintops_sanitize_payment_plan( (string) ($job->payment_plan ?? 'deposit_online') );
    $deposit_info = paintops_calculate_deposit( (float) $estimate->estTotal, $payment_plan );
    $can_pay_online = $payment_plan === 'deposit_online' && function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready() && function_exists( 'paintops_create_stripe_checkout_session' ) && $deposit_info['requires_deposit'];
    $already_paid = (int) $estimate->deposit_paid === 1;
    $already_accepted = (int) $estimate->accepted === 1;
    // Payment-processing interstitial (2026-07-19, audit P1): returning
    // from Stripe with ?paid=1 does NOT prove payment (the webhook is the
    // proof, and it's async), but it DOES mean the customer very likely
    // just paid, so the worst thing this page can do is show them the
    // "Accept & Pay Deposit Online" button again, the double-payment
    // window the audit flagged. While ?paid=1 and the webhook hasn't
    // landed yet: show a neutral "finalizing" notice, suppress the pay
    // form, and auto-reload every 5s (up to ~1 min) so the page flips to
    // the real Deposit Paid state on its own. The wording deliberately
    // never claims "paid" on the URL flag alone (that would be spoofable);
    // the badge states still come only from webhook-set DB fields.
    $payment_processing = isset( $_GET['paid'] ) && !$already_paid && $can_pay_online;
    $status_notice = '';
    if ( $payment_processing ) {
        // The data-po-poll attribute drives assets/js/customer-poll.js
        // (printed at the end of the body via the enqueue API): reload up
        // to 12 times, 5s apart, until the webhook-set paid state lands.
        $status_notice = '<div class="paintops-quote-notice" data-po-poll="po_pay_poll">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'] ) ) {
        $status_notice = '<div class="paintops-quote-notice paintops-quote-notice-warn">Payment was not completed. You can try again below whenever you\'re ready.</div>';
    } elseif ( !$already_accepted && !$already_paid && !empty( $estimate->declined_date ) ) {
        // Previously declined but re-accept is allowed: acknowledge it and
        // leave the accept form available below.
        $status_notice = '<div class="paintops-quote-notice paintops-quote-notice-warn">You declined this ' . esc_html( strtolower( $quote_word ) ) . ' on ' . esc_html( date( 'M jS, Y', strtotime( (string) $estimate->declined_date ) ) ) . '. Changed your mind? You can still accept below.</div>';
    }
    // Whether there's anything excluded on this estimate, if so, the
    // "Show Quote Details" toggle below includes it (see
    // paintops_render_quote_body_fragment()) and the Accept button is
    // gated behind a required checkbox confirming it was reviewed. The
    // total protects cash flow; this is what protects against "that's
    // not what I thought I was paying for" after the fact. See the
    // matching server-side check in paintops_process_quote_accept().
    $exclusions = trim( stripslashes( (string) ($estimate->scope_notes ?? '') ) );
    ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php 
    echo esc_html( $quote_word );
    ?> from <?php 
    echo esc_html( $company['name'] );
    ?></title>
<?php 
    paintops_customer_doc_styles();
    ?>
</head>
<body class="po-doc">
<div class="paintops-quote-card">
    <?php 
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped at assignment or assembled above from escaped and fixed internal parts
    echo wp_kses( $status_notice, paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?>
    <?php 
    if ( !empty( $company['logo'] ) ) {
        // (2026-07-19, audit P1) The logo used to live only on the buried
        // printable page; this accept page IS the user's brand to their
        // customer, so it leads with the logo when one is set.
        ?>
        <img src="<?php 
        echo esc_url( (string) $company['logo'] );
        ?>" alt="<?php 
        echo esc_attr( (string) $company['name'] );
        ?>" style="max-height:56px;max-width:220px;display:block;margin:0 0 10px;">
    <?php 
    }
    ?>
    <div class="paintops-quote-meta"><?php 
    echo esc_html( $company['name'] );
    ?></div>
    <h1><?php 
    echo esc_html( $cat_name );
    ?> <?php 
    echo esc_html( $quote_word );
    ?></h1>
    <div class="paintops-quote-meta">For: <?php 
    echo esc_html( trim( $job->custFirst . ' ' . $job->custLast ) );
    ?></div>
    <?php 
    if ( !empty( $job->custServiceAddress ) ) {
        ?>
        <div class="paintops-quote-meta"><?php 
        echo esc_html( $job->custServiceAddress );
        ?></div>
    <?php 
    }
    ?>

    <?php 
    // Cents shown here too (was number_format(...,0)), so the hero
    // total always matches the itemized breakdown to the penny.
    ?>
    <div class="paintops-quote-total"><?php 
    echo esc_html( paintops_currency_symbol() . number_format( (float) $estimate->estTotal, 2 ) );
    ?></div>

    <details class="paintops-quote-toggle">
        <summary>Show Quote Details</summary>
        <div class="paintops-quote-toggle-body">
            <?php 
    paintops_render_quote_body_fragment( $estimate, $job, false );
    ?>
            <p><a href="<?php 
    echo esc_url( paintops_customer_quote_full_url( (int) $estimate->estID ) );
    ?>">Open a printable version &rarr;</a></p>
        </div>
    </details>

    <?php 
    if ( $already_paid ) {
        ?>
        <p><span class="paintops-status-badge">Deposit Paid</span></p>
        <p>Thanks! Your deposit has been received and this job is on our schedule.</p>
    <?php 
    } elseif ( $already_accepted ) {
        ?>
        <p><span class="paintops-status-badge">Accepted</span></p>
        <p>Thanks for accepting! We'll be in touch shortly to schedule the work.</p>
    <?php 
    } elseif ( $payment_processing ) {
        ?>
        <?php 
        /* Just returned from Stripe, webhook not landed yet: no pay
           button (double-payment guard), the notice above explains and
           the page auto-refreshes into the Deposit Paid state. */
        ?>
    <?php 
    } elseif ( function_exists( 'paintops_quote_is_expired' ) && paintops_quote_is_expired( $estimate ) ) {
        ?>
        <p><span class="paintops-status-badge">Expired</span></p>
        <p>This quote expired on <?php 
        echo esc_html( gmdate( 'F j, Y', (int) paintops_quote_expiration_ts( $estimate ) ) );
        ?>. Prices can change over time, so it can no longer be accepted online.</p>
        <p>Want updated pricing? Use the email or phone link below and we'll refresh it for you.</p>
    <?php 
    } else {
        ?>
        <?php 
        if ( $error_message !== '' ) {
            ?>
            <p style="color:#a94442;"><?php 
            echo esc_html( $error_message );
            ?></p>
        <?php 
        }
        ?>

        <?php 
        if ( $deposit_info['requires_deposit'] ) {
            ?>
            <p>A deposit of <strong><?php 
            echo esc_html( paintops_currency_symbol() . number_format( $deposit_info['amount'], 2 ) );
            ?></strong> (<?php 
            echo esc_html( (string) $deposit_info['percent'] );
            ?>%) is required before work begins. The remaining <strong><?php 
            echo esc_html( paintops_currency_symbol() . number_format( $deposit_info['remaining'], 2 ) );
            ?></strong> is due upon completion.</p>
        <?php 
        } elseif ( $payment_plan === 'no_deposit' ) {
            ?>
            <p>No deposit is required for this job. Payment is due upon completion of work.</p>
        <?php 
        } else {
            ?>
            <p>Payment is due upon completion of work.</p>
        <?php 
        }
        ?>

        <form method="post">
            <?php 
        wp_nonce_field( 'paintops_accept_quote_' . (int) $estimate->estID, 'paintops_accept_nonce' );
        ?>
            <?php 
        if ( $exclusions !== '' ) {
            ?>
                <label class="paintops-quote-ack">
                    <input type="checkbox" name="paintops_exclusions_ack" value="1" required>
                    <span>I've reviewed the quote details above, including the exclusions, and understand what's included in this price.</span>
                </label>
            <?php 
        }
        ?>
            <?php 
        // Terms & Conditions (v1.0.105): business-level terms, shown
        // expandable ABOVE the signature input so the typed name
        // covers them. Renders nothing until the owner saves terms.
        $quote_terms = ( function_exists( 'paintops_quote_terms' ) ? paintops_quote_terms() : '' );
        if ( $quote_terms !== '' ) {
            ?>
            <details style="margin:16px 0;border:1px solid #e5e5e5;border-radius:6px;padding:10px 14px;">
                <summary style="cursor:pointer;font-size:14px;font-weight:600;color:#444;">Terms &amp; Conditions</summary>
                <div style="font-size:13px;color:#555;line-height:1.6;margin-top:10px;white-space:pre-line;"><?php 
            echo wp_kses_post( $quote_terms );
            ?></div>
            </details>
            <p style="font-size:12px;color:#777;margin:0 0 4px;">By accepting you agree to the Terms &amp; Conditions above.</p>
            <?php 
        }
        ?>
            <?php 
        // Outside the Terms guard (2026-08-31 walkthrough): the accept-by date must show even before the owner has saved any Terms, since acceptance is blocked after it passes.
        ?>
            <?php 
        $paintops_cp_validity = ( function_exists( 'paintops_quote_validity_line' ) ? paintops_quote_validity_line( $estimate ) : '' );
        ?>
            <?php 
        if ( $paintops_cp_validity !== '' ) {
            ?>
                <p style="font-size:12px;color:#777;margin:0 0 4px;"><?php 
            echo esc_html( $paintops_cp_validity );
            ?></p>
            <?php 
        }
        ?>
            <?php 
        // (2026-07-19, audit P1) Typed-name acceptance: the old
        // bare button left a legally thin record (a date only).
        // The typed full name + timestamp + IP are stored with
        // the acceptance (see paintops_process_quote_accept) and
        // shown on the staff quote view, an e-signature-grade
        // record a GC can be held to.
        ?>
            <label style="display:block;margin:16px 0 6px;font-size:14px;font-weight:600;" for="paintops_accept_name">Type your full name to accept</label>
            <input type="text" id="paintops_accept_name" name="paintops_accept_name" required autocomplete="name" autocapitalize="words"
                   placeholder="<?php 
        echo esc_attr( trim( $job->custFirst . ' ' . $job->custLast ) );
        ?>"
                   style="width:100%;box-sizing:border-box;padding:12px;font-size:16px;border:1px solid #ccc;border-radius:6px;">
            <p style="font-size:12px;color:#777;margin:6px 0 0;">Typing your name acts as your signature accepting this <?php 
        echo esc_html( strtolower( $quote_word ) );
        ?>.</p>
            <button type="submit" name="paintops_accept_quote" value="1" class="paintops-accept-btn">
                <?php 
        echo wp_kses_post( ( $can_pay_online ? 'Accept &amp; Pay Deposit Online' : 'Accept This Quote' ) );
        ?>
            </button>
        </form>
        <details style="margin-top:20px;">
            <summary style="cursor:pointer;color:#888;font-size:13px;">Not interested? Let us know</summary>
            <form method="post" style="margin-top:10px;">
                <?php 
        wp_nonce_field( 'paintops_accept_quote_' . (int) $estimate->estID, 'paintops_accept_nonce' );
        ?>
                <textarea name="paintops_decline_reason" rows="2" placeholder="Optional: mind telling us why? (price, timing, went another way...)" style="width:100%;box-sizing:border-box;padding:10px;font-size:14px;border:1px solid #ccc;border-radius:6px;"></textarea>
                <button type="submit" name="paintops_decline_quote" value="1" style="margin-top:8px;background:#fff;color:#a05252;border:1px solid #d8b4b4;padding:8px 16px;font-size:13px;border-radius:6px;cursor:pointer;">Decline this <?php 
        echo esc_html( strtolower( $quote_word ) );
        ?></button>
            </form>
        </details>
        <?php 
        if ( !empty( $company['email'] ) || !empty( $company['phone'] ) ) {
            ?>
            <p style="font-size:13px;color:#666;margin-top:18px;">Questions before accepting?
                <?php 
            if ( !empty( $company['email'] ) ) {
                ?>
                    <a href="mailto:<?php 
                echo esc_attr( $company['email'] );
                ?>?subject=<?php 
                echo esc_attr( rawurlencode( 'Question about my ' . $quote_word ) );
                ?>">Email us</a><?php 
                echo esc_html( ( !empty( $company['phone'] ) ? ' or ' : '' ) );
                ?>
                <?php 
            }
            ?>
                <?php 
            if ( !empty( $company['phone'] ) ) {
                ?>
                    <a href="tel:<?php 
                echo esc_attr( preg_replace( '/[^0-9+]/', '', (string) $company['phone'] ) );
                ?>">call <?php 
                echo esc_html( $company['phone'] );
                ?></a>
                <?php 
            }
            ?>
                and we will be happy to help.
            </p>
        <?php 
        }
        ?>
    <?php 
    }
    ?>
    <?php 
    if ( function_exists( 'paintops_powered_by_footer_html' ) ) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this paintops_* builder escapes every dynamic value internally before returning HTML
        echo wp_kses( paintops_powered_by_footer_html(), paintops_kses_allowed_html(), paintops_kses_protocols() );
    }
    ?>
</div>
<?php 
    paintops_customer_doc_scripts();
    ?>
</body>
</html>
    <?php 
}

/**
 * Everything that makes up "the actual quote", price breakdown (or the
 * simplified single-line total when no line items were entered), the
 * work description, exclusions (if any), and "our standard" text. Shared
 * between the standalone printable quote page (paintops_render_public_quote_details(),
 * which wraps this in a letterhead header + print/back toolbar) and the
 * expandable "Show Quote Details" section on the accept page
 * (paintops_render_customer_quote_page()), so the two never drift apart.
 * $include_next_steps is only true on the standalone page, its "use
 * the Accept button above" wording doesn't make sense once this same
 * content is embedded inline just above that same button.
 */
function paintops_render_quote_body_fragment(  $estimate, $job, bool $include_next_steps = true  ) : void {
    $jobCat = (int) $job->jobCat;
    $custFirst = (string) $job->custFirst;
    $custLast = (string) $job->custLast;
    $isBid = (int) ($job->isBid ?? $estimate->isBid);
    // job-level fact, not the estimate row's stale snapshot
    $baseCatName = ( function_exists( 'paintops_get_category_details' ) ? (string) paintops_get_category_details( $jobCat, 0 ) : '' );
    $quoteWord = ( (string) ($job->quote_label ?? 'quote') === 'estimate' ? 'Estimate' : 'Quote' );
    if ( $isBid === 1 ) {
        $jobCatName = esc_html( (string) $job->jobTitle ) . ' Bid Proposal';
        $quoteLang = 'Bid';
        $quoteLangTotal = '';
        $workLang = 'Scope';
    } else {
        $jobCatName = esc_html( trim( $baseCatName . ' ' . $quoteWord ) ) . ' for ' . esc_html( trim( $custFirst . ' ' . $custLast ) );
        $quoteLang = $quoteWord;
        $quoteLangTotal = $quoteWord;
        $workLang = 'Description';
    }
    $estTotal = (float) $estimate->estTotal;
    // Sales Tax/VAT amount saved with this specific quote (see
    // paintops_tax_percent()/paintops_tax_label() in paintops.php),
    // $estTotal already has this baked in (it's the actual amount
    // charged), this is just so it can be broken out as its own line
    // for the customer rather than a silent part of the total.
    $estTax = (float) ($estimate->estTax ?? 0);
    $supplyDesc = [
        $estimate->supplyDesc1,
        $estimate->supplyDesc2,
        $estimate->supplyDesc3,
        $estimate->supplyDesc4,
        $estimate->supplyDesc5
    ];
    $supplyCost = [
        $estimate->supplyCost1,
        $estimate->supplyCost2,
        $estimate->supplyCost3,
        $estimate->supplyCost4,
        $estimate->supplyCost5
    ];
    // Overflow line items (slots 6-20), see paintops_decode_supply_extra_items()
    // in paintops.php. Appended onto the same two parallel arrays so
    // everything below (which just loops over $supplyDesc by index) picks
    // them up automatically with no further changes.
    if ( function_exists( 'paintops_decode_supply_extra_items' ) ) {
        foreach ( paintops_decode_supply_extra_items( $estimate->supplyExtra ?? null ) as $paintops_extra_item ) {
            $supplyDesc[] = $paintops_extra_item['desc'];
            $supplyCost[] = $paintops_extra_item['cost'];
        }
    }
    $has_line_items = false;
    foreach ( $supplyDesc as $desc ) {
        if ( trim( (string) $desc ) !== '' ) {
            $has_line_items = true;
            break;
        }
    }
    // Whether to itemize is a deliberate per-quote choice made on the
    // estimate form (see the "Show an itemized price breakdown to the
    // customer?" checkbox in estimates.php), not something to guess from
    // whether the line-item fields happen to be filled in, staff often
    // fill those in for their own reference/costing even on quotes meant
    // to show the customer just one total. Still falls back to the
    // simple total if the flag is on but there's nothing to itemize.
    $show_itemized = (int) ($estimate->show_itemized ?? 0) === 1 && $has_line_items;
    $priceListBody = '';
    if ( $show_itemized ) {
        $priceListHeadLabel = 'Unit Price';
        $totalSupplyCost = array_sum( array_map( 'floatval', $supplyCost ) );
        foreach ( $supplyDesc as $i => $desc ) {
            if ( trim( (string) $desc ) === '' ) {
                continue;
            }
            $priceListBody .= '<tr><td style="padding:8px;border:1px solid #ccc;">' . esc_html( (string) $desc ) . '</td><td style="padding:8px;border:1px solid #ccc;">' . esc_html( paintops_currency_symbol() . number_format( (float) $supplyCost[$i], 2 ) ) . '</td></tr>';
        }
        $otherCostDiff = $estTotal - $totalSupplyCost - $estTax;
        $priceListBody .= '<tr><td style="padding:8px;border:1px solid #ccc;">Misc material/operational/' . esc_html( strtolower( ( function_exists( 'paintops_spelling' ) ? paintops_spelling( 'Labor', 'Labour' ) : 'Labor' ) ) ) . ' expense</td><td style="padding:8px;border:1px solid #ccc;">' . esc_html( paintops_currency_symbol() . number_format( $otherCostDiff, 2 ) ) . '</td></tr>';
        if ( $estTax > 0 ) {
            $priceListBody .= '<tr><td style="padding:8px;border:1px solid #ccc;">' . esc_html( ( function_exists( 'paintops_tax_label' ) ? paintops_tax_label() : 'Sales Tax' ) ) . '</td><td style="padding:8px;border:1px solid #ccc;">' . esc_html( paintops_currency_symbol() . number_format( $estTax, 2 ) ) . '</td></tr>';
        }
    } else {
        $priceListHeadLabel = 'Price';
        $priceListBody = '<tr><td style="padding:8px;border:1px solid #ccc;">' . esc_html( (string) $job->jobTitle ) . '<br>Please see &quot;' . esc_html( $workLang ) . ' of Work&quot; below for more details.</td><td style="padding:8px;border:1px solid #ccc;">' . esc_html( paintops_currency_symbol() . number_format( $estTotal, 2 ) ) . '</td></tr>';
        if ( $estTax > 0 ) {
            $priceListBody .= '<tr><td style="padding:8px;border:1px solid #ccc;font-size:11px;color:#666;" colspan="2">Includes ' . esc_html( ( function_exists( 'paintops_tax_label' ) ? paintops_tax_label() : 'Sales Tax' ) ) . ' of ' . esc_html( paintops_currency_symbol() . number_format( $estTax, 2 ) ) . '.</td></tr>';
        }
    }
    $ourStandards = ( function_exists( 'paintops_get_category_details' ) ? (string) paintops_get_category_details( $jobCat, 2 ) : '' );
    $dateOnly = date( 'Y-m-d', strtotime( (string) $estimate->createdOn ) );
    // Renamed from "Scope & Exclusions", the quote's own Description of
    // Work above already is the scope; this field only ever holds what's
    // NOT included, so labeling it "Exclusions" is what it actually is.
    // No callout-box styling either, just another plain quote section
    // like Description of Work and Our Standard.
    $exclusions = trim( stripslashes( (string) ($estimate->scope_notes ?? '') ) );
    ?>
  <h3><?php 
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped at assignment or assembled above from escaped and fixed internal parts
    echo esc_html( $jobCatName );
    ?><br><?php 
    echo esc_html( (string) $job->custServiceAddress );
    ?></h3>
  <p><?php 
    echo esc_html( $quoteLang );
    ?> #<?php 
    echo (int) $job->id;
    ?>-<?php 
    echo (int) $estimate->estID;
    ?> Created on: <?php 
    echo esc_html( $dateOnly );
    ?></p>

  <?php 
    // Inline styles so the table lays out correctly in EVERY consumer,
    // incl. the staff quote view (estimates.php), which has no wrapper CSS
    // for it; matches the .paintops-quote-full/.paintops-quote-toggle-body
    // table rules so nothing changes on the customer pages.
    ?>
  <table style="width:100%;border-collapse:collapse;margin-bottom:20px;">
    <thead>
      <tr style="background:#f2f2f2;text-align:left;">
        <th style="padding:8px;border:1px solid #ccc;">Description</th>
        <th style="padding:8px;border:1px solid #ccc;"><?php 
    echo esc_html( $priceListHeadLabel );
    ?></th>
      </tr>
    </thead>
    <tbody><?php 
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped at assignment or assembled above from escaped and fixed internal parts
    echo wp_kses( $priceListBody, paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?></tbody>
    <tfoot>
      <tr>
        <td style="text-align:right;padding:8px;border:1px solid #ccc;"><strong>Total <?php 
    echo esc_html( $quoteLangTotal );
    ?></strong></td>
        <td style="padding:8px;border:1px solid #ccc;"><strong><?php 
    echo esc_html( paintops_currency_symbol() . number_format( $estTotal, 2 ) );
    ?></strong></td>
      </tr>
    </tfoot>
  </table>

  <div style="margin-bottom:20px;">
    <h3><?php 
    echo esc_html( $workLang );
    ?> of Work</h3>
    <?php 
    // estDetails is RICH TEXT (contenteditable editor). esc_html here
    // turned every tag into visible text on the customer's accept page
    // (literal <br> mid-sentence, Mike's screenshot 2026-07-21) while
    // the staff print view already rendered it through wp_kses_post.
    // Same treatment on both: formatting survives, scripts don't.
    ?>
    <div style="margin:0;"><?php 
    echo wp_kses_post( stripslashes( (string) $estimate->estDetails ) );
    ?></div>
  </div>

  <?php 
    if ( $exclusions !== '' ) {
        ?>
  <div style="margin-bottom:20px;">
    <h3>Exclusions</h3>
    <p style="margin:0;"><?php 
        echo wp_kses( nl2br( esc_html( $exclusions ) ), paintops_kses_allowed_html(), paintops_kses_protocols() );
        ?></p>
  </div>
  <?php 
    }
    ?>

  <?php 
    if ( $ourStandards !== '' ) {
        ?>
  <div style="margin-bottom:20px;">
    <h3>Our Standard</h3>
    <p style="margin:0;"><?php 
        echo wp_kses( nl2br( esc_html( (string) $ourStandards ) ), paintops_kses_allowed_html(), paintops_kses_protocols() );
        ?></p>
  </div>
  <?php 
    }
    ?>

  <?php 
    if ( $include_next_steps && $isBid !== 1 ) {
        ?>
  <div>
    <h3>What&rsquo;s Next?</h3>
    <p style="margin:0;">To accept, use the Accept button on the quote summary page (link above), or just reply to your quote email or text.</p>
  </div>
  <?php 
    }
    ?>
    <?php 
}

/**
 * The full itemized quote: line items (or the simplified single-line
 * total when no line items were entered), the work description, "our
 * standard" text, and a print/Save-as-PDF link, essentially the same
 * content office staff already sees internally on the itemized quote
 * view/PDF, just re-rendered from $estimate/$job directly instead of
 * reusing that page (which lives behind a staff-only login and is tied
 * up with edit/replace actions that have no place on a customer-facing
 * page). No Accept action here on purpose, that only ever happens on
 * the summary page (see paintops_render_customer_quote_page()), which
 * links here and which this links back to.
 */
function paintops_render_public_quote_details(  $estimate, $job  ) : void {
    $company = paintops_company_profile();
    $isBid = (int) ($job->isBid ?? $estimate->isBid);
    $quoteWord = ( (string) ($job->quote_label ?? 'quote') === 'estimate' ? 'Estimate' : 'Quote' );
    $quoteLang = ( $isBid === 1 ? 'Bid' : $quoteWord );
    $backUrl = paintops_customer_quote_url( (int) $estimate->estID );
    ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php 
    echo esc_html( $quoteLang );
    ?> from <?php 
    echo esc_html( $company['name'] );
    ?></title>
<?php 
    paintops_customer_doc_styles();
    ?>
</head>
<body class="po-doc po-doc-full">
<div class="paintops-quote-toolbar">
    <a href="<?php 
    echo esc_url( $backUrl );
    ?>">&larr; Back to quote summary</a>
    &nbsp;|&nbsp;
    <a href="#" onclick="window.print();return false;">Print / Save as PDF</a>
</div>
<div class="paintops-quote-full">
  <div style="display:flex;justify-content:space-between;align-items:flex-start;border-bottom:2px solid #333;margin-bottom:20px;padding-bottom:10px;">
    <div>
      <?php 
    if ( !empty( $company['logo'] ) ) {
        ?>
        <a href="<?php 
        echo esc_url( $company['website'] );
        ?>"><img style="max-width:150px;" src="<?php 
        echo esc_url( $company['logo'] );
        ?>" alt="<?php 
        echo esc_attr( $company['name'] );
        ?>"></a><br>
      <?php 
    }
    ?>
      <strong><?php 
    echo esc_html( $company['name'] );
    ?></strong><br>
      <?php 
    echo esc_html( $company['address1'] );
    ?><br>
      <?php 
    echo esc_html( $company['address2'] );
    ?><br>
      <?php 
    echo esc_html( $company['email'] );
    ?>
    </div>
    <div style="text-align:right;">
      <strong><?php 
    echo esc_html( $quoteLang );
    ?> For:</strong><br>
      <?php 
    echo esc_html( trim( $job->custFirst . ' ' . $job->custLast ) );
    ?><br>
      <?php 
    echo esc_html( (string) $job->custServiceAddress );
    ?><br>
      <?php 
    echo esc_html( trim( (string) $job->custServiceCity . (( !empty( $job->custServiceState ) ? ', ' . $job->custServiceState : ',' )) ) );
    ?> <?php 
    echo esc_html( (string) $job->custServiceZip );
    ?><br>
      <?php 
    echo esc_html( (string) $job->custEmail );
    ?>
    </div>
  </div>

  <?php 
    paintops_render_quote_body_fragment( $estimate, $job, true );
    ?>
  <?php 
    $quote_validity_full = ( function_exists( 'paintops_quote_validity_line' ) ? paintops_quote_validity_line( $estimate ) : '' );
    if ( $quote_validity_full !== '' ) {
        echo '<div style="margin-top:22px;font-size:12px;color:#555;">' . esc_html( $quote_validity_full ) . '</div>';
    }
    // Terms & Conditions in FULL on the printable/formal page (v1.0.105),
    // this is the document a GC files, so no collapsing here.
    $quote_terms_full = ( function_exists( 'paintops_quote_terms' ) ? paintops_quote_terms() : '' );
    if ( $quote_terms_full !== '' ) {
        ?>
  <div style="margin-top:28px;border-top:1px solid #ddd;padding-top:16px;">
    <h3 style="font-size:15px;margin:0 0 8px;">Terms &amp; Conditions</h3>
    <div style="font-size:12px;color:#444;line-height:1.55;white-space:pre-line;"><?php 
        echo wp_kses_post( $quote_terms_full );
        ?></div>
  </div>
  <?php 
    }
    ?>
  <?php 
    if ( function_exists( 'paintops_powered_by_footer_html' ) ) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this paintops_* builder escapes every dynamic value internally before returning HTML
        echo wp_kses( paintops_powered_by_footer_html(), paintops_kses_allowed_html(), paintops_kses_protocols() );
    }
    ?>
</div>
</body>
</html>
    <?php 
}

/**
 * Minimal standalone page shell for error/confirmation states that don't
 * need the full quote layout (invalid link, quote not found, thank-you).
 */
/**
 * Register + print the shared stylesheet for the standalone customer
 * documents through the enqueue API. Called inside each document's
 * <head>; wp_print_styles() with an explicit handle prints the
 * registered <link> tag on the spot.
 */
function paintops_customer_doc_styles() : void {
    wp_register_style(
        'paintops-customer-doc',
        plugins_url( 'assets/css/customer-doc.css', PAINTOPS_PLUGIN_FILE ),
        [],
        PAINTOPS_VERSION
    );
    wp_print_styles( ['paintops-customer-doc'] );
}

/**
 * Same, for the payment-finalizing poll script: printed near the end of
 * the document body on the public quote/invoice pages.
 */
function paintops_customer_doc_scripts() : void {
    wp_register_script(
        'paintops-customer-poll',
        plugins_url( 'assets/js/customer-poll.js', PAINTOPS_PLUGIN_FILE ),
        [],
        PAINTOPS_VERSION,
        false
    );
    wp_print_scripts( ['paintops-customer-poll'] );
}

function paintops_render_customer_quote_shell(  string $body_html  ) : void {
    ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quote</title>
<?php 
    paintops_customer_doc_styles();
    ?>
</head>
<body class="po-doc po-doc-shell">
<div class="paintops-quote-shell"><?php 
    echo wp_kses( $body_html, paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?></div>
</body>
</html>
    <?php 
}

/**
 * Email the business owner the first time a customer opens their quote
 * link, the "someone's looking at it right now" signal, distinct from
 * (and earlier than) the accepted/deposit-paid notifications below. Only
 * ever fires once per quote (see paintops_mark_quote_viewed() above).
 */
function paintops_notify_owner_quote_viewed(  $job, $estimate  ) : void {
    $profile = paintops_company_profile();
    $to = ( $profile['email'] ?: get_option( 'admin_email' ) );
    $subject = "Quote viewed: {$job->custFirst} {$job->custLast} (Job #{$job->id})";
    $message = "A customer just opened their quote for the first time.\n\n" . "Customer: {$job->custFirst} {$job->custLast}\n" . "Address: {$job->custServiceAddress}\n" . "Quote total: \${$estimate->estTotal}\n\n" . "This can be a good moment to follow up if they don't accept within a day or two.";
    wp_mail(
        $to,
        $subject,
        $message,
        paintops_mail_headers()
    );
}

/**
 * Email the business owner when a customer accepts a quote without an
 * online payment (offline deposit, no deposit, or online payment
 * unavailable), there's no Stripe webhook to notify them in this case.
 */
function paintops_notify_owner_quote_declined(  $job, $estimate, string $reason  ) : void {
    $profile = paintops_company_profile();
    $to = ( $profile['email'] ?: get_option( 'admin_email' ) );
    $subject = "Quote declined: {$job->custFirst} {$job->custLast} (Job #{$job->id})";
    $message = "A customer declined their quote online.\n\n" . "Customer: {$job->custFirst} {$job->custLast}\n" . "Address: {$job->custServiceAddress}\n" . "Quote total: \${$estimate->estTotal}\n" . (( $reason !== '' ? "Their reason: {$reason}\n" : "No reason given.\n" )) . "\nTheir link still works if they change their mind; you can also revise and re-send the quote, or mark the lead Not Won on the Job Board.";
    wp_mail(
        $to,
        $subject,
        $message,
        paintops_mail_headers()
    );
}

function paintops_notify_owner_quote_accepted(  $job, $estimate  ) : void {
    $profile = paintops_company_profile();
    $to = ( $profile['email'] ?: get_option( 'admin_email' ) );
    $subject = "Quote accepted: {$job->custFirst} {$job->custLast} (Job #{$job->id})";
    $message = "A customer just accepted their quote online.\n\n" . "Customer: {$job->custFirst} {$job->custLast}\n" . "Address: {$job->custServiceAddress}\n" . "Quote total: \${$estimate->estTotal}\n\n" . "No online deposit was collected for this job (payment plan: {$job->payment_plan}). Follow up to arrange payment/scheduling as usual.";
    wp_mail(
        $to,
        $subject,
        $message,
        paintops_mail_headers()
    );
}

// end paintops_fs()->is__premium_only()