<?php

/**
 * Plugin Name: PaintOps
 * Plugin URI: https://paintoperations.com
 * Description: Estimating, scheduling, job tracking, crews, invoicing, and profit reporting for painting contractors.
 * Version: 1.0.275
 * Author: PaintOps
 * Author URI: https://paintoperations.com/the-paintops-story/
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: paintops
 * Requires at least: 6.2
 * Requires PHP: 7.4
 *
 */
// 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.DB.DirectDatabaseQuery.SchemaChange -- version-gated install/upgrade migrations for the plugin's own tables, the one place schema changes belong.
// 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.
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * ============================================================
 * Freemius: free/premium auto-swap
 * ============================================================
 *
 * If a customer ever has both the free (wp.org) build and the premium
 * build of PaintOps installed at the same time, only the one that
 * loads first should actually define anything, otherwise both
 * copies would redeclare the same functions and fatal. Whichever
 * loses this race just registers its basename with the SDK and goes
 * dormant; Freemius then auto-deactivates whichever copy shouldn't be
 * running. Per Freemius's own integration docs, the rest of this
 * file's logic has to live inside the "else" branch below so a
 * "losing" copy truly does nothing.
 */
if ( function_exists( 'paintops_fs' ) ) {
    paintops_fs()->set_basename( false, __FILE__ );
} else {
    /**
     * DO NOT REMOVE THIS IF, IT IS ESSENTIAL FOR THE
     * `function_exists` CALL ABOVE TO PROPERLY WORK.
     *
     * This is the real integration snippet generated by the Freemius
     * Dashboard for the PaintOps product (ID 33591). If it ever needs
     * to be regenerated: Freemius Dashboard -> PaintOps -> SDK Integration.
     *
     * REMAINING SETUP STEP: download the SDK from
     * https://github.com/Freemius/wordpress-sdk ("Code > Download ZIP"),
     * unzip it, and place the folder (renamed to "freemius") at
     * paintops_v104/vendor/freemius/start.php, i.e. a /vendor/ folder
     * that is a sibling of this file, with /freemius/ inside it. Until
     * that folder exists, paintops_fs() safely returns false and the whole
     * site keeps running as free-tier, nothing fatals in the meantime.
     */
    if ( !function_exists( 'paintops_fs' ) ) {
        // Create a helper function for easy SDK access.
        function paintops_fs() {
            global $paintops_fs;
            if ( !isset( $paintops_fs ) ) {
                $freemius_sdk = dirname( __FILE__ ) . '/vendor/freemius/start.php';
                // Guard against a fatal error if the SDK folder hasn't been
                // uploaded yet, see the setup note above.
                if ( !file_exists( $freemius_sdk ) ) {
                    $paintops_fs = false;
                    return $paintops_fs;
                }
                // Include Freemius SDK.
                require_once $freemius_sdk;
                // NOTE (2026-07-22 audit): a 'wp_org_gatekeeper' entry that
                // used to sit in the init array below was REMOVED. It is not
                // a real Freemius parameter (the SDK ignores unknown keys)
                // and its random-secret-looking value would read as a leaked
                // credential to a wp.org reviewer. If the real Freemius
                // secret is ever needed (dev mode only), take it from the
                // Freemius dashboard. Keep this init array free of inline
                // comments: the Freemius deployment processor regenerates
                // it in both builds (2026-07-24, the premium build produced
                // an empty main file while comments sat inside the array).
                $paintops_fs = fs_dynamic_init( array(
                    'id'               => '33591',
                    'slug'             => 'paintops',
                    'type'             => 'plugin',
                    'public_key'       => 'pk_4752bfea14d9f8a9046ff11d2d06d',
                    'is_premium'       => false,
                    'premium_suffix'   => 'Pro',
                    'has_addons'       => false,
                    'has_paid_plans'   => true,
                    'is_org_compliant' => true,
                    'menu'             => array(
                        'slug'           => 'paintops',
                        'override_exact' => true,
                        'contact'        => false,
                        'support'        => false,
                        'parent'         => array(
                            'slug' => 'paintops',
                        ),
                    ),
                    'is_live'          => true,
                ) );
            }
            return $paintops_fs;
        }

        // Init Freemius (no-op until the SDK folder is uploaded).
        if ( paintops_fs() ) {
            // Signal that SDK was initiated.
            do_action( 'paintops_fs_loaded' );
            // Tone down the SDK notice interjections to match the product voice.
            paintops_fs()->override_i18n( array(
                'yee-haw'  => 'Success',
                'right-on' => 'Thank you',
            ) );
            // Brand the opt-in (connect) screen. Without this Freemius
            // shows a generic gray placeholder icon, which is the very
            // first thing a new user sees after clicking Activate. The
            // PWA icons already ship in the zip, so reuse the 192px one.
            paintops_fs()->add_filter( 'plugin_icon', function () {
                return dirname( __FILE__ ) . '/assets/pwa/icon-192.png';
            } );
            // Swap the default corporate opt-in paragraph for a greeting
            // in PaintOps's voice (Mike's call, 2026-07-20, after walking
            // the install as a new user on Coastal Standard). The
            // disclosure substance is kept: what opting in shares, and
            // that Skip is fine. Buttons, permission list, and the fine
            // print below are Freemius's own template and stay untouched.
            paintops_fs()->add_filter(
                'connect_message',
                function ( $message, $first_name ) {
                    $hello = ( is_string( $first_name ) && '' !== trim( $first_name ) ? sprintf( 'Hey %s, welcome!', esc_html( trim( $first_name ) ) ) : 'Welcome!' );
                    return $hello . ' Thanks for installing <b>PaintOps</b>.' . ' Opt in to get security and feature updates by email, plus a heads-up when new tools for painters land.' . ' Opting in also shares some basic, non-sensitive info about your WordPress setup, which helps make PaintOps more compatible with your site.' . ' Prefer not to? Click Skip and everything still works.';
                },
                10,
                2
            );
            // The PREMIUM build shows a different first screen: a license
            // key prompt, with "Activate Free Version" tucked into a gray
            // bar at the bottom. Mike walked it as a new user (2026-07-20)
            // and flagged the free path as buried. wp.org installs get the
            // free build and never see this screen, but anyone holding the
            // Pro zip directly (Mike testing, direct customers) does, so
            // point at the try-first path right in the message. Note the
            // filter tag's odd hyphen/underscore mix; that is Freemius's
            // actual tag in templates/connect.php, do not "fix" it.
            paintops_fs()->add_filter(
                'connect-message_on-premium',
                function ( $message, $first_name ) {
                    $hello = ( is_string( $first_name ) && '' !== trim( $first_name ) ? sprintf( 'Hey %s, welcome!', esc_html( trim( $first_name ) ) ) : 'Welcome!' );
                    return $hello . ' If you already purchased <b>PaintOps Pro</b>, paste your license key below and you are all set.' . ' Just want to try PaintOps first? Click <b>Activate Free Version</b> at the bottom of this box.' . ' The free version has everything you need to quote, schedule, and run jobs, and you can upgrade from inside the app any time.';
                },
                10,
                2
            );
            // NOTE: we intentionally do NOT redirect Freemius's connect_url /
            // after_*_url to our own Settings page. Freemius's opt-in
            // hijack mechanism only works on a page whose registered menu
            // slug matches its own ('PaintOps'), which our Settings page
            // ('paintops-settings') doesn't, pointing it there breaks the
            // opt-in without any error, leaving the install stuck in a
            // "pending connection" state. Freemius's own default activation
            // page (registered under our Dashboard parent) handles this
            // correctly out of the box.
        }
    }
    define( 'PAINTOPS_VERSION', '1.0.275' );
    define( 'PAINTOPS_PLUGIN_FILE', __FILE__ );
    define( 'PAINTOPS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    /**
     * Whether this site currently has an active PaintOps Pro license.
     *
     * This is the single choke point every paid-tier feature in the plugin
     * checks. Route all "is this Pro?" logic through here rather than
     * calling Freemius directly elsewhere, so if the licensing backend
     * ever changes, there's exactly one place to update.
     */
    function paintops_is_pro() : bool {
        $fs = ( function_exists( 'paintops_fs' ) ? paintops_fs() : false );
        if ( !$fs || !method_exists( $fs, 'can_use_premium_code' ) ) {
            return false;
        }
        return (bool) $fs->can_use_premium_code();
    }

    /**
     * Is the Freemius install still waiting for its FIRST connection?
     *
     * True only while the site has neither opted in nor skipped, the one
     * state where Freemius's connect screen must be allowed to render and
     * where its admin pages (pricing, account) don't exist yet.
     *
     * Deliberately NOT plain is_activation_mode(): on a PREMIUM build with a
     * free activation (Mike's testing setup, 2026-07-21, and any trial or
     * lapsed license later), the SDK keeps an internal "requires license
     * activation" flag set, so is_activation_mode() stays true FOREVER and
     * the Dashboard link re-landed on the license screen on every visit.
     * Once the install is registered (opted in, with or without a license)
     * or anonymous (skipped), the connect screen has done its job: route to
     * the app and let each feature gate itself at runtime.
     */
    function paintops_fs_needs_connect() : bool {
        $fs = ( function_exists( 'paintops_fs' ) ? paintops_fs() : false );
        if ( !$fs || !method_exists( $fs, 'is_activation_mode' ) || !$fs->is_activation_mode() ) {
            return false;
        }
        if ( method_exists( $fs, 'is_registered' ) && $fs->is_registered() ) {
            return false;
        }
        if ( method_exists( $fs, 'is_anonymous' ) && $fs->is_anonymous() ) {
            return false;
        }
        return true;
    }

    /**
     * URL to send free-tier users who want to upgrade. Falls back to the
     * PaintOps Business settings page if Freemius isn't wired up yet.
     */
    function paintops_upgrade_url() : string {
        $fs = ( function_exists( 'paintops_fs' ) ? paintops_fs() : false );
        // Until the Freemius opt-in has been completed (or skipped), the SDK
        // registers NO admin pages, so get_upgrade_url()'s
        // admin.php?page=paintops-pricing target 404s with a WP permissions
        // error. Send people to the connect screen instead; after the site
        // is connected the pricing page exists and the normal URL works.
        if ( $fs && paintops_fs_needs_connect() && method_exists( $fs, 'get_activation_url' ) ) {
            return $fs->get_activation_url();
        }
        if ( $fs && method_exists( $fs, 'get_upgrade_url' ) ) {
            return $fs->get_upgrade_url();
        }
        return admin_url( 'admin.php?page=paintops-settings' );
    }

    /**
     * Standard "this is a Pro feature" teaser box, used anywhere a
     * free-tier user hits a paid-only feature or the usage cap.
     */
    function paintops_render_upgrade_notice(  string $feature, string $description = ''  ) : void {
        $upgrade_url = esc_url( paintops_upgrade_url() );
        echo '<div class="paintops-upgrade-notice" style="border:1px solid #e0c36a;background:#fff8e5;border-radius:8px;padding:20px;margin:16px 0;">';
        echo '<h3 style="margin-top:0;">🔒 ' . esc_html( $feature ) . ' is a PaintOps Pro feature</h3>';
        if ( $description !== '' ) {
            echo '<p>' . esc_html( $description ) . '</p>';
        }
        echo wp_kses( '<p><a class="button button-primary" href="' . esc_url( $upgrade_url ) . '">Upgrade to Pro</a></p>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '</div>';
    }

    /**
     * One-line pointer shown when a direct URL reaches a page whose
     * implementation exists only in the premium build. The free build hides
     * the nav items for these pages, so this renders only on a direct link.
     */
    function paintops_render_pro_pointer(  string $feature  ) : void {
        echo '<p style="margin:16px 0;color:#555;">' . esc_html( $feature ) . ' is part of PaintOps Pro. See paintoperations.com for details.</p>';
    }

    /**
     * Enqueue one of the plugin's bundled stylesheets (assets/css/SLUG.css).
     * Safe to call mid-render: WordPress prints late-enqueued styles in the
     * footer on both admin and front-end pages.
     */
    function paintops_enqueue_style(  string $slug  ) : void {
        $handle = 'paintops-' . $slug;
        if ( !wp_style_is( $handle, 'registered' ) ) {
            wp_register_style(
                $handle,
                plugins_url( 'assets/css/' . $slug . '.css', PAINTOPS_PLUGIN_FILE ),
                [],
                PAINTOPS_VERSION
            );
        }
        wp_enqueue_style( $handle );
    }

    /**
     * Enqueue one of the plugin's bundled scripts (assets/js/SLUG.js), in the
     * footer. $inline optionally attaches a small dynamic fragment (config,
     * nonces, ids) before or after the file through the scripts API.
     */
    function paintops_enqueue_script(  string $slug, string $inline = '', string $position = 'before'  ) : void {
        $handle = 'paintops-' . $slug;
        if ( !wp_script_is( $handle, 'registered' ) ) {
            wp_register_script(
                $handle,
                plugins_url( 'assets/js/' . $slug . '.js', PAINTOPS_PLUGIN_FILE ),
                [],
                PAINTOPS_VERSION,
                true
            );
        }
        wp_enqueue_script( $handle );
        if ( $inline !== '' ) {
            wp_add_inline_script( $handle, $inline, ( $position === 'after' ? 'after' : 'before' ) );
        }
    }

    /**
     * Attach a dynamic CSS fragment through the styles API instead of an
     * inline style tag. Late calls print with the footer styles.
     */
    function paintops_add_inline_style(  string $css  ) : void {
        static $paintops_css_n = 0;
        $handle = 'paintops-inline-css-' . ++$paintops_css_n;
        wp_register_style(
            $handle,
            false,
            [],
            PAINTOPS_VERSION
        );
        wp_enqueue_style( $handle );
        wp_add_inline_style( $handle, $css );
    }

    /**
     * Attach a dynamic script fragment through the scripts API instead of an
     * inline script tag. Prints with the footer scripts, in call order.
     */
    function paintops_add_inline_script(  string $js  ) : void {
        static $paintops_js_n = 0;
        $handle = 'paintops-inline-js-' . ++$paintops_js_n;
        wp_register_script(
            $handle,
            false,
            [],
            PAINTOPS_VERSION,
            true
        );
        wp_enqueue_script( $handle );
        wp_add_inline_script( $handle, $js );
    }

    /**
     * The app's base stylesheet plus the active theme's variables/extras,
     * through the styles API. Repeat calls no-op. Called early (head) by the
     * hooks below on the app's own screens, and again (harmlessly) by the
     * router's design-system require sites.
     */
    function paintops_enqueue_app_style() : void {
        // The app is per-user dynamic; page caches must never serve it stale.
        if ( is_user_logged_in() && !defined( 'DONOTCACHEPAGE' ) ) {
            define( 'DONOTCACHEPAGE', true );
        }
        $handle = 'paintops-app';
        if ( !wp_style_is( $handle, 'registered' ) ) {
            wp_register_style(
                $handle,
                plugins_url( 'assets/css/paintops-app.css', PAINTOPS_PLUGIN_FILE ),
                [],
                PAINTOPS_VERSION
            );
        }
        if ( !wp_style_is( $handle, 'enqueued' ) ) {
            wp_enqueue_style( $handle );
            $paintops_theme_css = ( function_exists( 'paintops_app_theme_css' ) ? trim( (string) paintops_app_theme_css() ) : '' );
            if ( $paintops_theme_css !== '' ) {
                wp_add_inline_style( $handle, $paintops_theme_css );
            }
        }
    }

    /**
     * Head-load the app stylesheet on the plugin's own screens so pages
     * never render unstyled: every PaintOps admin page, and the front-end
     * app page.
     */
    function paintops_enqueue_app_assets_early(  $hook = ''  ) : void {
        if ( is_admin() ) {
            if ( strpos( (string) $hook, 'paintops' ) === false ) {
                return;
            }
        } else {
            $paintops_app_page = ( function_exists( 'paintops_get_app_page_id' ) ? (int) paintops_get_app_page_id() : 0 );
            if ( $paintops_app_page <= 0 || !is_page( $paintops_app_page ) ) {
                return;
            }
        }
        paintops_enqueue_app_style();
    }

    add_action( 'admin_enqueue_scripts', 'paintops_enqueue_app_assets_early' );
    add_action( 'wp_enqueue_scripts', 'paintops_enqueue_app_assets_early' );
    /**
     * The credit line an admin can opt in to showing at the bottom of
     * customer quotes and invoices. Centralized here so the wording is a
     * one-line change (or a paintops_powered_by_label filter).
     */
    function paintops_powered_by_label() : string {
        return (string) apply_filters( 'paintops_powered_by_label', 'Powered by PaintOps' );
    }

    /**
     * Whether to show the credit line on customer quotes and invoices.
     * Strictly opt-in per wp.org guideline 10, on every tier: hidden until
     * the site admin intentionally enables it under Quote and Invoice
     * Branding. Never default this to shown.
     */
    function paintops_show_powered_by() : bool {
        return get_option( 'paintops_show_powered_by', '0' ) === '1';
    }

    /**
     * HTML credit footer for customer-facing quote/invoice documents (print,
     * PDF, and the public token pages). Returns '' unless the admin opted in,
     * so callers can echo it unconditionally.
     *
     * PLAIN TEXT, no hyperlink (2026-07-22 pre-launch audit): wp.org guideline
     * 10 treats promotional LINKS on a user's public pages as grounds for
     * rejection. The credit text plus the bare domain keeps the branding
     * while staying review-safe. Do not reintroduce an anchor tag here.
     */
    function paintops_powered_by_footer_html() : string {
        if ( !paintops_show_powered_by() ) {
            return '';
        }
        return '<p style="margin:22px 0 0;padding-top:10px;border-top:1px solid #eee;text-align:center;font-size:11px;color:#9a9a9a;">' . esc_html( paintops_powered_by_label() ) . ' &middot; paintoperations.com</p>';
    }

    /**
     * One-time form token: stops a browser REFRESH from re-submitting an
     * insert-type form and duplicating the record (Mike 2026-07-23). Nonces
     * don't help here, a nonce stays valid for hours, so the resubmitted
     * POST passes it. paintops_once_field() prints a hidden token unique to
     * that RENDER of the form; paintops_once_check() burns it in a transient
     * on first use, so the identical resubmit (refresh, back-button re-post)
     * is recognized and skipped. Handlers add the check to their condition
     * chain, so a duplicate simply no-ops and the page renders the state
     * that already includes the first submit, the least confusing outcome.
     * Only used on NON-idempotent handlers (inserts); replace-style saves
     * (the Financials form) are naturally refresh-safe and skip this.
     * A form without the field passes the check (back-compat).
     */
    function paintops_once_field() : string {
        return '<input type="hidden" name="paintops_once" value="' . esc_attr( wp_generate_password( 20, false, false ) ) . '">';
    }

    function paintops_once_check() : bool {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the once token is a replay guard that always travels with a nonce, every consuming form handler verifies its own nonce first
        $paintops_once = sanitize_text_field( wp_unslash( $_POST['paintops_once'] ?? '' ) );
        if ( $paintops_once === '' || !preg_match( '/^[A-Za-z0-9]{10,32}$/', $paintops_once ) ) {
            return true;
        }
        if ( get_transient( 'paintops_once_' . $paintops_once ) ) {
            return false;
            // this exact form render was already processed
        }
        set_transient( 'paintops_once_' . $paintops_once, 1, 15 * MINUTE_IN_SECONDS );
        return true;
    }

    /**
     * Plain-text credit line for the quote/invoice EMAILS (which are plain
     * text, not HTML). Returns '' when hidden.
     */
    function paintops_powered_by_footer_text() : string {
        if ( !paintops_show_powered_by() ) {
            return '';
        }
        return paintops_powered_by_label() . ' - paintoperations.com';
    }

    /**
     * Freemius's own "Account" admin page (the one that lists your license,
     * billing, etc.) is rendered entirely by vendor/freemius/templates, we
     * never edit those files directly since the whole vendor/ folder gets
     * replaced on every SDK update. But its default look (WP-core blue links
     * and buttons) doesn't match the rest of the PaintOps admin, which uses
     * our #4682B4 accent everywhere else. This prints a small scoped CSS
     * override, gated so it only ever loads on that one page.
     *
     * Scoped to ".wrap.fs-section", the outer wrapper Freemius's own
     * account.php template uses (confirmed unique to that template; nothing
     * else in WP core or PaintOps uses that class combination), so this
     * can't leak onto any other admin screen even if the page-detection
     * check below is ever loosened.
     */
    function paintops_account_page_css_overlay() : void {
        $fs = ( function_exists( 'paintops_fs' ) ? paintops_fs() : false );
        if ( !$fs || !method_exists( $fs, 'is_admin_page' ) || !$fs->is_admin_page( 'account' ) ) {
            return;
        }
        paintops_add_inline_style( '
        .wrap.fs-section .nav-tab-active { border-bottom-color:#4682B4; box-shadow:inset 0 -3px 0 #4682B4; }
        .wrap.fs-section .nav-tab-active, .wrap.fs-section .nav-tab-active:hover { color:#2c3338; }
        .wrap.fs-section a { color:#4682B4; }
        .wrap.fs-section a:hover { color:#35648a; }
        .wrap.fs-section .button-primary { background:#4682B4; border-color:#3a6f99; box-shadow:0 1px 0 #3a6f99; text-shadow:0 -1px 1px #3a6f99, 1px 0 1px #3a6f99, 0 1px 1px #3a6f99, -1px 0 1px #3a6f99; }
        .wrap.fs-section .button-primary:hover, .wrap.fs-section .button-primary:focus { background:#3a6f99; border-color:#2f5a7d; }
        .wrap.fs-section #fs_account .fs-key-value-table code,
        .wrap.fs-section #fs_account .fs-key-value-table var { color:#4682B4; }
        .wrap.fs-section .postbox .hndle, .wrap.fs-section .postbox h3 { border-top:3px solid #4682B4; }
    ' );
    }

    add_action( 'admin_enqueue_scripts', 'paintops_account_page_css_overlay' );
    /**
     * Return a WordPress-prefixed PaintOps table name.
     *
     * Existing table slugs keep the cs_ names for compatibility, but now use
     * the active WordPress database prefix, e.g. wp_x6s0x_cs_jobs.
     */
    function paintops_table(  string $slug  ) : string {
        global $wpdb;
        $allowed = [
            'jobs'              => 'cs_jobs',
            'estimates'         => 'cs_estimates',
            'calendar'          => 'cs_calendar',
            'timeoff'           => 'cs_timeoff',
            'job_financials'    => 'cs_job_financials',
            'job_expenses'      => 'cs_job_expenses',
            'job_worker_hours'  => 'cs_job_worker_hours',
            'job_payments'      => 'cs_job_payments',
            'customers'         => 'cs_customers',
            'job_paint_colors'  => 'cs_job_paint_colors',
            'job_photos'        => 'cs_job_photos',
            'job_checkins'      => 'cs_job_checkins',
            'vehicle_log'       => 'cs_vehicle_log',
            'job_invoices'      => 'cs_job_invoices',
            'business_expenses' => 'cs_business_expenses',
            'crews'             => 'cs_crews',
            'comm_log'          => 'cs_comm_log',
        ];
        if ( !isset( $allowed[$slug] ) ) {
            return '';
        }
        return $wpdb->prefix . $allowed[$slug];
    }

    /**
     * Capability required to use the PaintOps app (staff-level access).
     *
     * Deliberately stricter than 'read', which every logged-in user has,
     * including the auto-created 'customer' role used for quote recipients.
     * 'edit_posts' is granted to Contributor and above but not Subscriber
     * or Customer, so customer accounts can never see job/financial data.
     */
    function paintops_user_can_manage() : bool {
        return current_user_can( 'edit_posts' );
    }

    /**
     * Capability required to view or edit anything financial: Profits,
     * profit-per-hour worker rankings, and a job's own Financials box
     * (expenses/revenue). Deliberately Administrator-only (manage_options),
     * stricter than paintops_user_can_manage(), a job/quote can be
     * created, edited, and scheduled by any staff-level user, but cost and
     * profit numbers are owner-level information.
     */
    function paintops_user_can_view_financials() : bool {
        if ( current_user_can( 'manage_options' ) ) {
            return true;
        }
        // A Painter/Helper can be individually opted into financials visibility
        // on PaintOps > Painters/Helpers (see paintops_worker_has_permission()).
        // Everyone else with this capability check already returned true above,
        // so reaching here means "not an Administrator", the permission flag
        // only ever grants anything to a Painter/Helper account, since every
        // call site of this function is already staff-only for non-workers.
        return paintops_worker_has_permission( 'view_financials' );
    }

    /**
     * The WP role slug used for hourly painters/helpers. A dedicated role
     * (rather than just picking any existing WP user for job assignment)
     * lets "Manage Painters/Helpers" show a clean, deliberate list, and
     * gives each of them a place to hang an hourly pay rate off of.
     */
    function paintops_worker_role_slug() : string {
        return 'paintops_worker';
    }

    /**
     * Register the Painter/Helper role if it doesn't already exist. Safe to
     * call on every request that needs it, add_role() is a no-op if the
     * role is already registered. Called from paintops_install(), which
     * itself re-runs on any version bump (see paintops_maybe_install_or_upgrade()),
     * so this reaches already-active sites without needing deactivate/reactivate.
     */
    function paintops_register_worker_role() : void {
        if ( !get_role( paintops_worker_role_slug() ) ) {
            add_role( paintops_worker_role_slug(), 'Painter/Helper', [
                'read' => true,
            ] );
        }
    }

    /**
     * The 5 individual permissions an Administrator can toggle per Painter/
     * Helper on PaintOps > Painters/Helpers (see paintops_render_workers_page()).
     * Keyed by a short slug used both as the user_meta suffix
     * (paintops_worker_permission_meta_key()) and the settings-form field name.
     *
     * 'default' is what an untouched worker (one whose checkbox has never been
     * saved either way) is treated as having. upload_photos/upload_receipts
     * default to true, since assigned workers already had unconditional photo/
     * receipt access before this permission system existed, defaulting them
     * to false would silently take away something every existing Painter/
     * Helper already relies on. The other three are new abilities a worker
     * could never do before, so they default to false (off) until an
     * Administrator specifically opts someone in.
     */
    function paintops_worker_permissions() : array {
        return [
            'view_financials' => [
                'label'   => 'Can manage financials',
                'default' => false,
                'summary' => 'See and edit the money side (gross, payments, hours, expenses) for jobs they are assigned to.',
                'tooltip' => 'Full view and edit access to Gross/Payout, Payments, Hours, and expenses, same as an Administrator, but only for jobs this person is assigned to. They can\'t see or manage financials for anyone else\'s job.',
            ],
            'create_jobs'     => [
                'label'   => 'Can create jobs',
                'default' => false,
                'summary' => 'Start a brand-new job or quote. Editing it afterward still needs staff access.',
                'tooltip' => 'Lets this person create a brand-new job or quote. It does not include editing one afterward, editing any existing job (even one they just created themselves) always requires staff (Administrator/Editor) access.',
            ],
            'create_quotes'   => [
                'label'   => 'Can create quotes',
                'default' => false,
                'summary' => 'Build a quote or estimate for a job they are already assigned to.',
                'tooltip' => 'Lets this person create a quote/estimate for a job they\'re assigned to. It does not let them create a brand-new job from scratch, see "Can create jobs" for that.',
            ],
            'upload_photos'   => [
                'label'   => 'Can upload photos',
                'default' => true,
                'summary' => 'Add before/after and damage photos to their assigned jobs.',
                'tooltip' => 'Lets this person add before/after or damage photos to jobs they\'re assigned to. On by default for every worker, matching how photos already worked before this permission existed.',
            ],
            'upload_receipts' => [
                'label'   => 'Can upload receipts',
                'default' => true,
                'summary' => 'Log an expense and attach a receipt photo on their assigned jobs.',
                'tooltip' => 'Lets this person log an expense (a materials receipt, a subcontractor charge, etc.) and attach a photo of it, for jobs they\'re assigned to. On by default for every worker, matching how receipts already worked before this permission existed.',
            ],
        ];
    }

    function paintops_worker_permission_meta_key(  string $perm  ) : string {
        return 'paintops_perm_' . $perm;
    }

    /**
     * Whether a given user (current user by default) has one of the 5
     * PaintOps > Painters/Helpers permissions above. Administrators/Editors
     * never need to consult this directly, every call site checks
     * paintops_user_can_manage() (or an equally-staff-level check) first and
     * short-circuits before reaching here, so in practice this only ever
     * matters for a Painter/Helper account.
     */
    function paintops_worker_has_permission(  string $perm, int $user_id = 0  ) : bool {
        $permissions = paintops_worker_permissions();
        if ( !isset( $permissions[$perm] ) ) {
            return false;
        }
        $user_id = ( $user_id > 0 ? $user_id : get_current_user_id() );
        if ( $user_id <= 0 ) {
            return false;
        }
        $raw = get_user_meta( $user_id, paintops_worker_permission_meta_key( $perm ), true );
        // An empty string means the checkbox has never been saved for this
        // user (get_user_meta() returns '' for a meta key that doesn't exist)
        //, fall back to the permission's own default rather than reading
        // that as "off."
        if ( $raw === '' ) {
            return $permissions[$perm]['default'];
        }
        return (bool) $raw;
    }

    /**
     * Whether the current user may create a brand-new job/quote. True for any
     * staff-level user (paintops_user_can_manage()). Also true for a Painter/
     * Helper account whose "Can create jobs" permission is checked on
     * PaintOps > Painters/Helpers. Deliberately scoped to *creating* a new
     * record only, editing an existing job (?editEid=) stays staff-only,
     * a materially bigger ability (touching any job already on the books,
     * not just adding a new one) than what this checkbox promises.
     */
    function paintops_user_can_create_job() : bool {
        if ( paintops_user_can_manage() ) {
            return true;
        }
        return paintops_worker_has_permission( 'create_jobs' );
    }

    /**
     * Whether the current user may create a quote/estimate/bid for a specific
     * job. True for any staff-level user. Also true for a Painter/Helper
     * account with the "Can create quotes" permission, but ONLY for a job
     * they're actually assigned to (paintops_user_can_view_job()), same
     * per-job model already used for photos/receipts, so this never lets a
     * worker create a quote against a job they have no other connection to.
     */
    function paintops_user_can_create_quote(  int $job_id  ) : bool {
        if ( paintops_user_can_manage() ) {
            return true;
        }
        if ( !paintops_worker_has_permission( 'create_quotes' ) ) {
            return false;
        }
        return $job_id > 0 && function_exists( 'paintops_user_can_view_job' ) && paintops_user_can_view_job( $job_id );
    }

    /**
     * An hourly pay rate for a specific worker, or 0.0 if they don't have
     * one set (e.g. they're not paid hourly, the business owner logging
     * their own hours to compute profit-per-hour, for instance, shouldn't
     * have their time counted as a labor expense to themselves).
     */
    function paintops_get_worker_hourly_rate(  int $user_id  ) : float {
        if ( $user_id <= 0 ) {
            return 0.0;
        }
        return max( 0.0, (float) get_user_meta( $user_id, 'paintops_hourly_rate', true ) );
    }

    /**
     * A team member's contact phone number, if they've agreed to be reached
     * by phone/SMS by others on their jobs. Set on PaintOps > Painters/
     * Helpers and surfaced as Call/Text links on the job view (see
     * paintops_job_team_contacts()). Empty string when none is on file,
     * always opt-in, never auto-populated from the WP account.
     */
    function paintops_get_worker_phone(  int $user_id  ) : string {
        if ( $user_id <= 0 ) {
            return '';
        }
        return trim( (string) get_user_meta( $user_id, 'paintops_worker_phone', true ) );
    }

    /**
     * A team member's short display nickname (e.g. "Joe" instead of the WP
     * username "jsmith_1987"), set on PaintOps > Painters/Helpers. Empty
     * string when none is on file.
     */
    function paintops_get_worker_nickname(  int $user_id  ) : string {
        if ( $user_id <= 0 ) {
            return '';
        }
        return trim( (string) get_user_meta( $user_id, 'paintops_worker_nickname', true ) );
    }

    /**
     * The best short label to show for a team member in lists (the job list,
     * assignment chips, etc.): their Painters/Helpers nickname if set,
     * otherwise their WP display name, otherwise their login. Never the raw
     * numeric ID.
     */
    function paintops_worker_display_label(  int $user_id  ) : string {
        $nick = paintops_get_worker_nickname( $user_id );
        if ( $nick !== '' ) {
            return $nick;
        }
        $user = get_userdata( $user_id );
        if ( $user ) {
            return ( $user->display_name !== '' ? $user->display_name : $user->user_login );
        }
        return '';
    }

    /**
     * Normalize a phone number to the digits (plus an optional leading "+")
     * that tel:/sms: links need, so "(941) 548-6870" becomes "9415486870"
     * for the href while the human-readable form is shown as the label.
     * Returns '' if there aren't enough digits to be a real number.
     */
    function paintops_phone_href_digits(  string $phone  ) : string {
        $phone = trim( $phone );
        if ( $phone === '' ) {
            return '';
        }
        $has_plus = strpos( $phone, '+' ) === 0;
        $digits = preg_replace( '/\\D+/', '', $phone );
        if ( strlen( (string) $digits ) < 7 ) {
            return '';
        }
        return (( $has_plus ? '+' : '' )) . $digits;
    }

    /**
     * Everyone who can be assigned to a job or scheduled for time off:
     * Administrators/Editors (the legacy "user_level > 6" check this
     * plugin has always used for staff-level users) plus anyone given the
     * Painter/Helper role. Without the second half, a painter added via
     * PaintOps > Painters/Helpers would never show up in the "Workers Or
     * Estimator(s)" checklist or the Manage Time Off list, since a custom
     * role with only the 'read' capability doesn't carry a user_level.
     *
     * Returns a list of objects with ->ID and ->display_name, sorted by
     * name, de-duplicated (an Administrator who is also flagged as a
     * Painter/Helper only appears once).
     */
    function paintops_get_assignable_workers() : array {
        global $wpdb;
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one indexed usermeta lookup for admin-level users, same query core's user screens run.
        $legacy_staff = $wpdb->get_results( $wpdb->prepare(
            "SELECT u.ID, u.display_name FROM %i u, %i m\n         WHERE (m.meta_key = %s AND m.meta_value > 6) AND m.user_id = u.ID",
            $wpdb->users,
            $wpdb->usermeta,
            $wpdb->prefix . 'user_level'
        ) );
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        if ( $wpdb->last_error ) {
            paintops_report_db_error();
        }
        $painter_helpers = get_users( [
            'role'   => paintops_worker_role_slug(),
            'fields' => ['ID', 'display_name'],
        ] );
        $by_id = [];
        foreach ( array_merge( ( $legacy_staff ?: [] ), $painter_helpers ) as $user ) {
            $by_id[(int) $user->ID] = (object) [
                'ID'           => (int) $user->ID,
                'display_name' => $user->display_name,
            ];
        }
        uasort( $by_id, static function ( $a, $b ) {
            return strcasecmp( $a->display_name, $b->display_name );
        } );
        return array_values( $by_id );
    }

    /**
     * Canonical parser for the hash-delimited worker-ID strings stored in
     * jobs.reqWorkers and timeoff.user_id (e.g. '#1##5##6#', or '#5#' for a
     * single worker). Historically three slightly different idioms parsed
     * these, explode('##') after trim('#'), preg_match('/#id#/'), and
     * timeoff.php's explode('#') + is_numeric filter, which all agreed on
     * well-formed input but diverged on malformed strings (explode('##')
     * silently loses IDs from a single-hash-separated value like '#1#5#').
     * This helper splits on single '#' and keeps every numeric part, so it
     * accepts anything the old parsers accepted plus the malformed cases,
     * returning unique ints in stored order. The SQL-side membership match
     * (LIKE '%#id#%') is unaffected and still works against the same
     * stored format.
     */
    function paintops_parse_worker_ids(  $raw  ) : array {
        $ids = [];
        foreach ( explode( '#', (string) $raw ) as $part ) {
            $part = trim( $part );
            if ( $part !== '' && ctype_digit( $part ) ) {
                $ids[] = (int) $part;
            }
        }
        return array_values( array_unique( $ids ) );
    }

    /**
     * Find an existing customer record by email, or create a new data-only
     * one. This never creates a WordPress user account, being in a
     * business's own customer records is normal record-keeping, but a real
     * login/account is a different thing entirely and should never happen
     * silently just because someone's email showed up on a job.
     */
    function paintops_find_or_create_customer(
        string $email,
        string $first_name = '',
        string $last_name = '',
        string $phone = '',
        string $address = '',
        string $city = '',
        string $zip = '',
        string $state = ''
    ) : int {
        global $wpdb;
        $email = sanitize_email( $email );
        $phone = sanitize_text_field( $phone );
        $table = paintops_table( 'customers' );
        if ( $email !== '' ) {
            $existing_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE email = %s ORDER BY id ASC LIMIT 1", $table, $email ) );
            if ( $existing_id > 0 ) {
                return $existing_id;
            }
        }
        // Email is optional on the job form, so plenty of customers only
        // ever have a phone number on file. Fall back to matching on that,
        // best effort only (a shared household/office line could match the
        // wrong person), but far better than silently never recording this
        // customer at all, which is what happened before this fallback
        // existed for anyone entered without an email.
        if ( $phone !== '' ) {
            $existing_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE phone = %s AND phone != '' ORDER BY id ASC LIMIT 1", $table, $phone ) );
            if ( $existing_id > 0 ) {
                return $existing_id;
            }
        }
        $wpdb->insert( $table, [
            'first_name'      => sanitize_text_field( $first_name ),
            'last_name'       => sanitize_text_field( $last_name ),
            'email'           => $email,
            'phone'           => sanitize_text_field( $phone ),
            'service_address' => sanitize_text_field( $address ),
            'service_city'    => sanitize_text_field( $city ),
            'service_zip'     => sanitize_text_field( $zip ),
            'service_state'   => sanitize_text_field( $state ),
        ], [
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s'
        ] );
        return (int) $wpdb->insert_id;
    }

    /**
     * Permanently delete a single job and EVERY record that hangs off it: its
     * estimates/quotes, financial summary, expenses, worker hours, payments,
     * paint colors, check-in sessions, invoices, and photos + receipts
     * (including the underlying WordPress media files). Also removes any
     * follow-up visit jobs whose parent_job_id points at it (walked a few
     * levels deep, with a cycle guard), so a punch-list or callback visit
     * never outlives its parent job.
     *
     * This is the SINGLE source of truth for a hard job delete. The old
     * ?delEid= handler deleted only the jobs row, leaving all of the above
     * orphaned in the database and the photo/receipt files stranded in the
     * Media Library; both that handler and the customer wipe
     * (paintops_delete_customer_deep) now call this so nothing is left behind.
     *
     * Deliberately does NOT touch records that merely REFERENCE a job but are
     * the business's own books, the Vehicle Log (mileage/expense) and Business
     * Overhead rows keep their optional job_id as-is, since deleting a job
     * shouldn't erase the company's tax records. Those reads never depend on
     * the job row still existing.
     *
     * IDs are cast through intval before being interpolated into the IN()
     * lists, so these list-based deletes carry no injection surface. Returns
     * the number of job rows removed (the job plus any follow-ups).
     */
    function paintops_delete_job_deep(  int $job_id  ) : int {
        global $wpdb;
        if ( $job_id <= 0 ) {
            return 0;
        }
        $jobs_t = paintops_table( 'jobs' );
        // Collect this job plus any follow-up visits hanging off it. One level
        // is the norm; the loop (with a small guard + seen-set) also catches a
        // follow-up of a follow-up and can't spin on a bad parent_job_id cycle.
        $ids = [$job_id];
        $seen = [
            $job_id => true,
        ];
        $front = [$job_id];
        $guard = 0;
        while ( $front && $guard++ < 25 ) {
            $in = implode( ',', array_fill( 0, count( $front ), '%d' ) );
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
            $children = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE parent_job_id IN ({$in})", array_merge( [$jobs_t], array_map( 'intval', $front ) ) ) ) );
            $front = [];
            foreach ( $children as $cid ) {
                if ( $cid > 0 && empty( $seen[$cid] ) ) {
                    $seen[$cid] = true;
                    $ids[] = $cid;
                    $front[] = $cid;
                }
            }
        }
        $id_in = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
        $id_args = array_map( 'intval', $ids );
        // Delete the media files behind this job's photos + receipts first, so
        // nothing is left stranded in the Media Library after the rows go.
        $photos_t = paintops_table( 'job_photos' );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
        $attachment_ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT attachment_id FROM %i WHERE job_id IN ({$id_in}) AND attachment_id IS NOT NULL", array_merge( [$photos_t], $id_args ) ) );
        foreach ( $attachment_ids as $att_id ) {
            $att_id = (int) $att_id;
            if ( $att_id > 0 ) {
                // true = skip trash, delete the attachment post and its files.
                wp_delete_attachment( $att_id, true );
            }
        }
        // Child rows keyed by job_id.
        foreach ( [
            'job_financials',
            'job_expenses',
            'job_worker_hours',
            'job_payments',
            'job_paint_colors',
            'job_photos',
            'job_checkins',
            'job_invoices'
        ] as $slug ) {
            $t = paintops_table( $slug );
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
            $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE job_id IN ({$id_in})", array_merge( [$t], $id_args ) ) );
        }
        // Estimates link back via the legacy camelCase jobID column, not job_id.
        $est_t = paintops_table( 'estimates' );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE jobID IN ({$id_in})", array_merge( [$est_t], $id_args ) ) );
        // Finally the job rows themselves.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
        return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ({$id_in})", array_merge( [$jobs_t], $id_args ) ) );
    }

    /*
     * TIMEZONE DESIGN NOTE (2026-07-21, replaces the removed
     * paintops_apply_site_timezone()). This plugin used to call
     * date_default_timezone_set(site tz) on plugins_loaded so every naive
     * date()/mktime()/strtotime() ran in the business's local time. WP core
     * pins PHP to UTC and Plugin Check flags the override as an ERROR
     * (other plugins' and core's date math silently skewed), so it is gone.
     * The convention that replaced it, applied across every file:
     *   - "now" is current_time('timestamp'), WP's local pseudo-epoch;
     *     rendering it with naive date() yields the site-local wall clock.
     *   - Stored wall-clock strings (job dates, checkin_at, DB datetimes)
     *     parse with naive strtotime() and render with naive date(); the
     *     UTC default cancels out on the round trip.
     *   - Real epochs (Stripe signature timestamps, wp_schedule_event,
     *     device sync clamps) stay time(); never mix the two kinds.
     * Full call-site audit: PROJECT-NOTES change log, 2026-07-21 entry.
     */
    /**
     * Return the Google Maps API key to use for address autocomplete,
     * static maps, and geocoding.
     *
     * Stored as an option so each install can supply its own key instead
     * of sharing a hardcoded one. On first upgrade to a version with this
     * option, paintops_migrate_missing_columns() seeds it with the key
     * that used to be hardcoded so existing installs keep working.
     */
    function paintops_maps_api_key() : string {
        return (string) get_option( 'paintops_maps_api_key', '' );
    }

    /**
     * Random per-install token used to authorize the manual
     * ?sendNotif=1 notification trigger without requiring a login
     * (needed because that endpoint is meant to be pinged by an
     * external cron/uptime service).
     */
    function paintops_notif_token() : string {
        $token = get_option( 'paintops_notif_token', '' );
        if ( !$token ) {
            $token = wp_generate_password( 32, false, false );
            update_option( 'paintops_notif_token', $token );
        }
        return (string) $token;
    }

    /**
     * Whether the "job starts tomorrow" email reminder (paintops_run_job_notifications()
     * in includes/job-manager-notifications.php) is turned on. Defaults to true
     * so every existing install keeps behaving exactly as it always has; an
     * Administrator can turn it off under PaintOps > Settings > Job
     * Notification Trigger. Checked once, right at the top of
     * paintops_run_job_notifications() itself, so it applies the same way whether
     * that function was reached via the daily WP-Cron event or the manual
     * ?sendNotif= backup URL, no need to gate both call sites separately.
     */
    function paintops_notifications_enabled() : bool {
        return get_option( 'paintops_notifications_enabled', '1' ) === '1';
    }

    /**
     * Whether the per-job Time Clock (Check In / Check Out) is enabled for
     * this business. Default ON so existing installs are unchanged; an
     * Administrator can switch it off under PaintOps > Business > Time Clock
     * for a shop that tracks hours some other way (or not at all). Read at the
     * single permission gate paintops_user_can_checkin() (job-checkin.php), so
     * turning it off hides the Time Clock box, refuses the live Check In/Out
     * POST, and blocks the offline-sync endpoint, all from this one flag.
     */
    function paintops_timeclock_enabled() : bool {
        return get_option( 'paintops_timeclock_enabled', '1' ) === '1';
    }

    /**
     * How many days a PaintOps team member stays logged in before WordPress
     * makes them sign in again. Exists because the WordPress defaults (2
     * days, or 14 with "Remember Me", a checkbox nobody ticks on a phone)
     * meant painters kept getting bounced to the login screen mid-job.
     * Editable under PaintOps > Painters/Helpers > Team Login Duration; 0 means
     * "leave the WordPress defaults alone". The value is validated against
     * the same fixed list the Settings dropdown offers, so a mangled option
     * value falls back to the 30-day default rather than something weird.
     */
    function paintops_login_duration_days() : int {
        $days = (int) get_option( 'paintops_login_duration_days', 30 );
        return ( in_array( $days, paintops_login_duration_choices(), true ) ? $days : 30 );
    }

    /**
     * The allowed values for the login-duration setting (0 = WordPress
     * default). Single source for both the Settings dropdown and the
     * validation in paintops_login_duration_days().
     */
    function paintops_login_duration_choices() : array {
        return [
            0,
            14,
            30,
            60,
            90,
            180
        ];
    }

    /**
     * NOTE (2026-07-19): paintops_nav_style() and its option
     * `paintops_nav_style` were RETIRED when the Pills / Plain Tabs styles
     * were removed in favor of the single unified app bar (bottom bar on
     * phones, matching top bar on desktop; see paintops_render_app_nav() in
     * manage-jobs.php). The old option may still sit in wp_options on
     * existing installs; it is simply never read. Sticky survives as the
     * one remaining nav preference.
     */
    /**
     * Whether the desktop app bar sticks to the top of the viewport as you
     * scroll (default off, the bar scrolls away like the rest of the page).
     * Phones always have the fixed bottom bar, so this only affects large
     * screens.
     */
    function paintops_nav_sticky() : bool {
        return get_option( 'paintops_nav_sticky', '0' ) === '1';
    }

    /**
     * Whether to hide the WordPress admin bar on the app page (2026-07-19,
     * Mike's ask, default ON): the black WP toolbar reads as clutter on an
     * app screen, especially on phones. App page only; the rest of the
     * site and wp-admin keep the bar. Toggle lives under Settings >
     * Appearance > Navigation.
     */
    function paintops_hide_admin_bar_on_app() : bool {
        return get_option( 'paintops_hide_admin_bar', '1' ) === '1';
    }

    function paintops_maybe_hide_admin_bar() : void {
        // Since 2026-07-22 the admin bar follows the App Page Display
        // settings per device class: it only hides where the site header &
        // footer are hidden. The old behavior (hidden for everyone on the
        // app page) surprised Mike on desktop, where chrome shows.
        if ( !is_admin() && function_exists( 'paintops_is_app_page_request' ) && paintops_is_app_page_request() && paintops_hide_admin_bar_on_app() && function_exists( 'paintops_app_chrome_hidden' ) && paintops_app_chrome_hidden() ) {
            add_filter( 'show_admin_bar', '__return_false' );
        }
    }

    add_action( 'wp', 'paintops_maybe_hide_admin_bar' );
    /**
     * Stretches the login cookie lifetime for PaintOps team members,
     * staff (edit_posts) and Painter/Helpers, to the configured number of
     * days. Applied whether or not "Remember Me" was ticked, which
     * deliberately makes that checkbox irrelevant for the team: the whole
     * point is that someone logging in from a phone on a job site never has
     * to remember it. Anyone else on the site (a role that can't see
     * PaintOps anyway) keeps the normal WordPress behavior.
     *
     * Takes effect on each user's NEXT login, an existing cookie keeps
     * its original expiration. max() so this only ever lengthens a session;
     * if another plugin already grants longer logins, it wins. A lost or
     * stolen phone is the flip side of a long session: changing that
     * worker's password (or deleting the account) invalidates their
     * sessions immediately.
     */
    function paintops_extend_login_duration(  $expiration, $user_id, $remember  ) {
        $days = paintops_login_duration_days();
        if ( $days <= 0 ) {
            return $expiration;
        }
        $user = get_userdata( (int) $user_id );
        if ( !$user ) {
            return $expiration;
        }
        $is_team = user_can( $user, 'edit_posts' ) || in_array( paintops_worker_role_slug(), (array) $user->roles, true );
        if ( !$is_team ) {
            return $expiration;
        }
        return max( (int) $expiration, $days * DAY_IN_SECONDS );
    }

    add_filter(
        'auth_cookie_expiration',
        'paintops_extend_login_duration',
        20,
        3
    );
    /**
     * Default company profile, job categories, and pricing rules.
     *
     * These were previously hardcoded throughout includes/manage-jobs.php and
     * includes/estimates.php as "Coastal Standard FL" specific values. They now
     * live in options (editable under PaintOps > Business) so any painting
     * business can configure their own identity and pricing without touching
     * code. The values below are the exact original hardcoded values, used both
     * as the one-time migration seed (so the live site's output doesn't change)
     * and as a fallback if an option is ever missing.
     */
    function paintops_default_company_profile() : array {
        // Identity fields ship BLANK so a brand-new install never prints
        // another business's name/address/logo on a quote. The settings form
        // shows placeholder hints (e.g. "Your Company Name") so it's obvious
        // what to fill in. Existing installs are unaffected: the seed only
        // writes this option when it's still null, and every already-active
        // site had it written once with its real values back when the option
        // was introduced. (These used to default to Coastal Standard FL's
        // details, which was correct only for that one original business.)
        return [
            'name'     => '',
            'address1' => '',
            'address2' => '',
            'phone'    => '',
            'email'    => '',
            'website'  => '',
            'logo'     => '',
            'country'  => 'US',
            'currency' => 'USD',
            'units'    => 'imperial',
        ];
    }

    function paintops_default_job_categories() : array {
        // Neutral starter placeholder. Ships generic so a new install isn't
        // pre-loaded with one shop's hand-picked video list; the owner adds
        // their own crew references under PaintOps > Business > Job Categories.
        // Generic color-confirmation note (no region-specific wording).
        $paint_quote_note = "All paint colors must be selected and confirmed by the homeowner. We recommend applying a sample to an inconspicuous area first to confirm the color looks right in the space's lighting before the full application.";
        return [
            1 => [
                'name'                => 'Residential Painting',
                'email_phrase'        => 'painting quote for your home',
                'standards'           => "Work areas will be professionally maintained and managed throughout the project, with attention to cleanliness and safety. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly. Any leftover paint or related materials will be left with the customer or removed, based on prior agreement.",
                'calculator'          => 'paint_sqft',
                'resource_title'      => '',
                'resource_links'      => '',
                'quote_note'          => $paint_quote_note,
                'default_quote_title' => '',
                'supply_hints'        => [
                    'i.e. 3 painters on-site',
                    'i.e. 3 Gallons SW Exterior Primer',
                    'i.e. Stucco Patch',
                    'i.e. 12 Tubes Caulking',
                    'i.e. 7 Gallons SW Emerald Interior Paint'
                ],
                'scope_components'    => [
                    1  => [
                        'label'      => 'Walls',
                        'text'       => 'Repaint all specified interior walls with primer as needed and two finish coats.',
                        'applies_to' => 'interior',
                    ],
                    2  => [
                        'label'      => 'Ceilings',
                        'text'       => 'Repaint ceilings in specified areas with a flat finish, one coat over existing color or two coats for a color change.',
                        'applies_to' => 'interior',
                    ],
                    3  => [
                        'label'      => 'Doors',
                        'text'       => 'Repaint interior doors, both sides, including door edges.',
                        'applies_to' => 'interior',
                    ],
                    4  => [
                        'label'      => 'Trim',
                        'text'       => 'Repaint trim throughout the specified areas, caulked and sanded as needed.',
                        'applies_to' => 'interior',
                    ],
                    5  => [
                        'label'      => 'Baseboard Caulking',
                        'text'       => 'Caulk baseboards to the wall as needed. Caulk will be applied to the top joint only, not the floor gap.',
                        'applies_to' => 'interior',
                    ],
                    6  => [
                        'label'      => 'Crown Molding',
                        'text'       => 'Repaint crown molding throughout the specified areas.',
                        'applies_to' => 'interior',
                    ],
                    7  => [
                        'label'      => 'Cabinets',
                        'text'       => 'Repaint cabinet doors, drawer fronts, and face frames. Cabinet interiors and hardware are not included unless listed separately.',
                        'applies_to' => 'interior',
                    ],
                    8  => [
                        'label'      => 'Stucco',
                        'text'       => 'Repaint all accessible exterior stucco surfaces. Minor crack patching is included; larger structural cracks are billed separately.',
                        'applies_to' => 'exterior',
                    ],
                    9  => [
                        'label'      => 'Siding',
                        'text'       => 'Repaint all accessible exterior siding, including visible seams and edges.',
                        'applies_to' => 'exterior',
                    ],
                    10 => [
                        'label'      => 'Soffits & Fascia',
                        'text'       => 'Repaint soffits and fascia boards along the roofline.',
                        'applies_to' => 'exterior',
                    ],
                    11 => [
                        'label'      => 'Front Door',
                        'text'       => 'Repaint front entry door, both sides, including frame and threshold trim.',
                        'applies_to' => 'exterior',
                    ],
                    12 => [
                        'label'      => 'Garage Door',
                        'text'       => 'Repaint garage door exterior face, excluding hardware and tracks.',
                        'applies_to' => 'exterior',
                    ],
                    13 => [
                        'label'      => 'Gates & Fencing',
                        'text'       => 'Repaint gates and attached fencing visible from the street-facing elevation.',
                        'applies_to' => 'exterior',
                    ],
                ],
            ],
            2 => [
                'name'                => 'Commercial Painting',
                'email_phrase'        => '',
                'standards'           => "Work areas will be professionally maintained and managed throughout the project, with attention to cleanliness and safety. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly. Any leftover paint or related materials will be left at the jobsite or location, based on prior agreement.",
                'calculator'          => 'paint_sqft',
                'resource_title'      => '',
                'resource_links'      => '',
                'quote_note'          => $paint_quote_note,
                'default_quote_title' => '',
                'supply_hints'        => [
                    'i.e. 3 painters on-site',
                    'i.e. 3 Gallons SW Exterior Primer',
                    'i.e. Stucco Patch',
                    'i.e. 12 Tubes Caulking',
                    'i.e. 7 Gallons SW Emerald Interior Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Office Interiors',
                        'text'       => 'Repaint office interior walls, two coats over existing color, during after-hours or scheduled off-peak times.',
                        'applies_to' => 'interior',
                    ],
                    2 => [
                        'label'      => 'Storefront',
                        'text'       => 'Repaint storefront exterior facade, including trim and entry doors, coordinated with business hours to minimize disruption.',
                        'applies_to' => 'exterior',
                    ],
                    3 => [
                        'label'      => 'Utility Room',
                        'text'       => 'Repaint utility/mechanical rooms with a durable, easy-clean finish.',
                        'applies_to' => 'interior',
                    ],
                    4 => [
                        'label'      => 'Restrooms',
                        'text'       => 'Repaint restroom walls with a moisture-resistant, scrubbable finish.',
                        'applies_to' => 'interior',
                    ],
                    5 => [
                        'label'      => 'Common Areas / Hallways',
                        'text'       => 'Repaint common areas and hallways, coordinated to maintain a clear path of travel during work hours.',
                        'applies_to' => 'interior',
                    ],
                    6 => [
                        'label'      => 'Ceilings',
                        'text'       => 'Repaint drop or drywall ceilings in specified areas with a flat finish.',
                        'applies_to' => 'interior',
                    ],
                    7 => [
                        'label'      => 'Doors & Frames',
                        'text'       => 'Repaint interior doors and frames throughout the specified suite(s).',
                        'applies_to' => 'interior',
                    ],
                ],
            ],
        ];
    }

    /**
     * Optional job-category templates.
     *
     * These are NOT loaded into new installs by default, they used to be
     * (as categories 3-7: Fence, Handyman, Home Watch, Storm Prep, Home
     * Maintenance), left over from Coastal Standard FL's original painting +
     * home-services business. A dedicated painting shop never sees them
     * unless they choose to. A small painter who also does other work can
     * add any of these from Business Settings with one click, fully
     * pre-filled with standards/resource tips/supply hints, instead of
     * typing a category from scratch.
     *
     * Keyed by a stable slug rather than a numeric ID, a numeric category
     * ID is only assigned once a template is actually added to a business's
     * live category list (see the "Add from Template" handler below).
     */
    function paintops_category_template_library() : array {
        return [
            'fence'                     => [
                'name'                => 'Fence',
                'email_phrase'        => '',
                'standards'           => "Areas will be maintained and managed professionally while conducting fence work (considering the work environment). All waste, including excess fence materials and packaging will be removed or recycled, and the areas cleaned of this material. Excess eligible materials such as hardware or paint will either be left with the customer or removed depending on the prior agreement.",
                'calculator'          => '',
                'resource_title'      => '',
                'resource_links'      => '',
                'quote_note'          => '',
                'default_quote_title' => 'Full privacy fence installation',
                'supply_hints'        => [
                    'i.e. 3 workers on-site',
                    'i.e. 300 fence panels',
                    'i.e. 24 4X4s and post caps',
                    'i.e. 48 bags of 50lb concrete',
                    'i.e. PT approved screws'
                ],
            ],
            'handyman'                  => [
                'name'         => 'Handyman Services',
                'email_phrase' => '',
                'standards'    => "Work areas will be kept clean, organized, and safe throughout the project. All debris, packaging, and excess materials will be removed or recycled responsibly. Usable leftover materials such as hardware or parts will be left with the customer or disposed of, depending on prior agreement.",
                'supply_hints' => [
                    'i.e. 2 workers on-site',
                    'i.e. Drywall patch compound',
                    'i.e. Misc hardware (screws, anchors, brackets)',
                    'i.e. Lumber/trim replacement',
                    'i.e. Hardware store run (fasteners, adhesive)'
                ],
            ],
            'repaint_interior'          => [
                'name'                => 'Interior Repaint',
                'email_phrase'        => 'interior repaint quote for your home',
                'standards'           => "Work areas will be professionally maintained and managed throughout the project, with attention to cleanliness and safety. Furniture and flooring in active work areas will be covered or protected. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly.",
                'calculator'          => 'paint_sqft',
                'default_quote_title' => 'Interior Repaint',
                'supply_hints'        => [
                    'i.e. 2 painters on-site',
                    'i.e. 5 Gallons SW Interior Primer',
                    'i.e. Drywall patch compound',
                    'i.e. 10 Tubes Caulking',
                    'i.e. 8 Gallons SW Emerald Interior Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Walls',
                        'text'       => 'Repaint all specified interior walls with primer as needed and two finish coats.',
                        'applies_to' => 'interior',
                    ],
                    2 => [
                        'label'      => 'Ceilings',
                        'text'       => 'Repaint ceilings in specified areas with a flat finish, one coat over existing color or two coats for a color change.',
                        'applies_to' => 'interior',
                    ],
                    3 => [
                        'label'      => 'Doors',
                        'text'       => 'Repaint interior doors, both sides, including door edges.',
                        'applies_to' => 'interior',
                    ],
                    4 => [
                        'label'      => 'Trim',
                        'text'       => 'Repaint trim throughout the specified areas, caulked and sanded as needed.',
                        'applies_to' => 'interior',
                    ],
                    5 => [
                        'label'      => 'Baseboards',
                        'text'       => 'Repaint baseboards throughout the specified areas, caulked to the wall as needed. Caulk will be applied to the top joint only, not the floor gap.',
                        'applies_to' => 'interior',
                    ],
                    6 => [
                        'label'      => 'Crown Molding',
                        'text'       => 'Repaint crown molding throughout the specified areas.',
                        'applies_to' => 'interior',
                    ],
                    7 => [
                        'label'      => 'Cabinets',
                        'text'       => 'Repaint cabinet doors, drawer fronts, and face frames. Cabinet interiors and hardware are not included unless listed separately.',
                        'applies_to' => 'interior',
                    ],
                    8 => [
                        'label'      => 'Staining',
                        'text'       => 'Apply stain and topcoat to specified wood surfaces, matching the sample approved by the customer.',
                        'applies_to' => 'interior',
                    ],
                ],
            ],
            'repaint_exterior'          => [
                'name'                => 'Exterior Repaint',
                'email_phrase'        => 'exterior repaint quote for your home',
                'standards'           => "Work areas will be professionally maintained and managed throughout the project, with attention to cleanliness and safety. Landscaping and walkways near work areas will be protected during prep and painting. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly.",
                'calculator'          => 'paint_sqft',
                'default_quote_title' => 'Exterior Repaint',
                'supply_hints'        => [
                    'i.e. 2 painters on-site',
                    'i.e. 5 Gallons SW Exterior Primer',
                    'i.e. Stucco Patch',
                    'i.e. 10 Tubes Caulking',
                    'i.e. 8 Gallons SW Exterior Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Stucco',
                        'text'       => 'Repaint all accessible exterior stucco surfaces. Minor crack patching is included; larger structural cracks are billed separately.',
                        'applies_to' => 'exterior',
                    ],
                    2 => [
                        'label'      => 'Siding',
                        'text'       => 'Repaint all accessible exterior siding, including visible seams and edges.',
                        'applies_to' => 'exterior',
                    ],
                    3 => [
                        'label'      => 'Brick',
                        'text'       => 'Paint or seal exterior brick surfaces as specified, using a breathable masonry-grade coating.',
                        'applies_to' => 'exterior',
                    ],
                    4 => [
                        'label'      => 'Gates & Fencing',
                        'text'       => "Repaint gates and attached fencing visible from the street-facing elevation.",
                        'applies_to' => 'exterior',
                    ],
                    5 => [
                        'label'      => 'Soffits & Fascia',
                        'text'       => 'Repaint soffits and fascia boards along the roofline.',
                        'applies_to' => 'exterior',
                    ],
                    6 => [
                        'label'      => 'Shutters & Trim',
                        'text'       => 'Repaint shutters and exterior trim, removed and re-hung if needed for full coverage.',
                        'applies_to' => 'exterior',
                    ],
                    7 => [
                        'label'      => 'Front Door',
                        'text'       => 'Repaint front entry door, both sides, including frame and threshold trim.',
                        'applies_to' => 'exterior',
                    ],
                    8 => [
                        'label'      => 'Garage Door',
                        'text'       => 'Repaint garage door exterior face, excluding hardware and tracks.',
                        'applies_to' => 'exterior',
                    ],
                ],
            ],
            'new_construction_interior' => [
                'name'                => 'New Construction - Interior',
                'email_phrase'        => 'new construction interior painting quote',
                'standards'           => "Work is scheduled and coordinated with the builder/GC's timeline. Surfaces are prepped and inspected prior to painting to confirm they're finish-ready. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly.",
                'calculator'          => 'paint_sqft',
                'default_quote_title' => 'New Construction Interior Painting',
                'supply_hints'        => [
                    'i.e. 3 painters on-site',
                    'i.e. 10 Gallons PVA Primer',
                    'i.e. Drywall patch compound',
                    'i.e. 20 Tubes Caulking',
                    'i.e. 12 Gallons Interior Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Caulk',
                        'text'       => 'Caulk all interior joints, gaps, and nail holes prior to priming as needed for a finish-ready surface.',
                        'applies_to' => 'interior',
                    ],
                    2 => [
                        'label'      => 'PVA Primer',
                        'text'       => 'Apply one coat of PVA primer to all new drywall surfaces prior to finish coats.',
                        'applies_to' => 'interior',
                    ],
                    3 => [
                        'label'      => 'Ceilings',
                        'text'       => 'Apply flat finish paint to all ceilings, one coat over primer.',
                        'applies_to' => 'interior',
                    ],
                    4 => [
                        'label'      => 'Baseboards',
                        'text'       => 'Prime and paint baseboards throughout, caulked to the wall prior to the finish coat.',
                        'applies_to' => 'interior',
                    ],
                    5 => [
                        'label'      => 'Trim',
                        'text'       => 'Prime and paint door/window casing and trim throughout.',
                        'applies_to' => 'interior',
                    ],
                    6 => [
                        'label'      => 'Doors',
                        'text'       => 'Prime and paint all interior doors, both sides and edges, prior to hardware installation.',
                        'applies_to' => 'interior',
                    ],
                    7 => [
                        'label'      => 'Walls',
                        'text'       => 'Apply two finish coats of paint to all interior walls per the approved color schedule.',
                        'applies_to' => 'interior',
                    ],
                ],
            ],
            'new_construction_exterior' => [
                'name'                => 'New Construction - Exterior',
                'email_phrase'        => 'new construction exterior painting quote',
                'standards'           => "Work is scheduled and coordinated with the builder/GC's timeline and is weather-dependent. Surfaces are prepped and inspected prior to painting to confirm they're finish-ready. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly.",
                'calculator'          => 'paint_sqft',
                'default_quote_title' => 'New Construction Exterior Painting',
                'supply_hints'        => [
                    'i.e. 3 painters on-site',
                    'i.e. 10 Gallons Exterior Primer',
                    'i.e. Stucco Patch',
                    'i.e. 20 Tubes Caulking',
                    'i.e. 12 Gallons Exterior Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Caulk Windows & Doors',
                        'text'       => 'Caulk windows and doors as needed. Caulk will be applied to inner joints only.',
                        'applies_to' => 'exterior',
                    ],
                    2 => [
                        'label'      => 'Primer',
                        'text'       => 'Apply one coat of exterior-grade primer to all bare/new exterior surfaces.',
                        'applies_to' => 'exterior',
                    ],
                    3 => [
                        'label'      => 'Paint',
                        'text'       => 'Apply two finish coats of exterior paint per the approved color schedule.',
                        'applies_to' => 'exterior',
                    ],
                    4 => [
                        'label'      => 'Soffits & Fascia',
                        'text'       => 'Prime and paint soffits and fascia boards along the full roofline.',
                        'applies_to' => 'exterior',
                    ],
                    5 => [
                        'label'      => 'Roof Eaves',
                        'text'       => 'Prime and paint exposed roof eaves and rafter tails as accessible.',
                        'applies_to' => 'exterior',
                    ],
                    6 => [
                        'label'      => 'Garage Door',
                        'text'       => 'Prime and paint garage door exterior face prior to final walkthrough.',
                        'applies_to' => 'exterior',
                    ],
                    7 => [
                        'label'      => 'Exterior Doors',
                        'text'       => 'Prime and paint all exterior doors, both sides, including frames and thresholds.',
                        'applies_to' => 'exterior',
                    ],
                ],
            ],
            'commercial'                => [
                'name'                => 'Commercial Repaint',
                'email_phrase'        => 'commercial painting quote',
                'standards'           => "Work is scheduled around business hours to minimize disruption to operations, tenants, or customers, per prior agreement. Work areas will be professionally maintained and managed throughout the project. All waste, including excess paint, materials, and packaging, will be cleaned up and either recycled or disposed of properly.",
                'calculator'          => 'paint_sqft',
                'default_quote_title' => 'Commercial Repaint',
                'supply_hints'        => [
                    'i.e. 3 painters on-site',
                    'i.e. 5 Gallons Commercial-Grade Primer',
                    'i.e. Drywall patch compound',
                    'i.e. 10 Tubes Caulking',
                    'i.e. 10 Gallons Commercial-Grade Paint'
                ],
                'scope_components'    => [
                    1 => [
                        'label'      => 'Office Interiors',
                        'text'       => 'Repaint office interior walls, two coats over existing color, during after-hours or scheduled off-peak times.',
                        'applies_to' => 'interior',
                    ],
                    2 => [
                        'label'      => 'Storefront',
                        'text'       => 'Repaint storefront exterior facade, including trim and entry doors, coordinated with business hours to minimize disruption.',
                        'applies_to' => 'exterior',
                    ],
                    3 => [
                        'label'      => 'Utility Room',
                        'text'       => 'Repaint utility/mechanical rooms with a durable, easy-clean finish.',
                        'applies_to' => 'interior',
                    ],
                    4 => [
                        'label'      => 'Restrooms',
                        'text'       => 'Repaint restroom walls with a moisture-resistant, scrubbable finish.',
                        'applies_to' => 'interior',
                    ],
                    5 => [
                        'label'      => 'Common Areas / Hallways',
                        'text'       => 'Repaint common areas and hallways, coordinated to maintain a clear path of travel during work hours.',
                        'applies_to' => 'interior',
                    ],
                    6 => [
                        'label'      => 'Ceilings',
                        'text'       => 'Repaint drop or drywall ceilings in specified areas with a flat finish.',
                        'applies_to' => 'interior',
                    ],
                    7 => [
                        'label'      => 'Doors & Frames',
                        'text'       => 'Repaint interior doors and frames throughout the specified suite(s).',
                        'applies_to' => 'interior',
                    ],
                ],
            ],
        ];
    }

    /**
     * Fill in the trade-agnostic-layer fields (calculator, resource tips,
     * quote note, supply hints, default quote title) with safe empty
     * defaults for any category that doesn't define them, keeps every
     * caller safe against undefined-index notices whether the category
     * came from defaults, a saved option, or a category a user just added
     * by hand in the Business Settings screen.
     */
    function paintops_normalize_job_category(  array $category  ) : array {
        // Back-compat: categories saved before a category could have more
        // than one calculator have a single 'calculator' string instead of
        // a 'calculators' array. Upgrade in place here (not a separate DB
        // migration script) so every caller only ever has to deal with
        // 'calculators' as an array, whether the data underneath is old or
        // new, same normalize-on-read pattern already used elsewhere in
        // this function (supply_hints, scope_components, etc.).
        if ( empty( $category['calculators'] ) && !empty( $category['calculator'] ) ) {
            $category['calculators'] = [(string) $category['calculator']];
        }
        unset($category['calculator']);
        $category = array_merge( [
            'name'                => '',
            'email_phrase'        => '',
            'standards'           => '',
            'calculators'         => [],
            'resource_title'      => '',
            'resource_links'      => '',
            'quote_note'          => '',
            'default_quote_title' => '',
            'desc_template'       => '',
            'supply_hints'        => [
                '',
                '',
                '',
                '',
                ''
            ],
            'scope_components'    => [],
        ], $category );
        // Each component is a checklist item offered on the New/Edit Quote
        // screen for this category: a short 'label' for the checkbox itself,
        // and a separately-editable 'text' snippet inserted into the
        // Description of Work when checked. Split into two fields (rather
        // than reusing the label as the inserted text) because a painter
        // wants the checklist to read like a punch list ("Caulk windows &
        // doors") while the text that actually lands in the customer-facing
        // quote needs to be a fuller, specific sentence ("Caulk windows and
        // doors as needed. Caulk will be applied to inner joints only.").
        // Normalized here (not just at read time in Business Settings) so
        // every caller, the settings page, the quote form, an import, a
        // hand-added category, gets the same safe shape regardless of
        // where the array came from.
        $normalized_components = [];
        foreach ( (array) $category['scope_components'] as $comp_id => $comp ) {
            if ( !is_array( $comp ) ) {
                continue;
            }
            // 'applies_to' lets a component be scoped to interior or exterior
            // work (see the Interior/Exterior checkboxes on the job form and
            // the filtering in estimates.php), e.g. "Baseboards" should only
            // turn up on an interior job, "Stucco" only on an exterior one.
            // Defaults to 'both' so components created before this field
            // existed (or entered without touching the selector) keep showing
            // up exactly as they always have, rather than quietly vanishing.
            $applies_to = (string) ($comp['applies_to'] ?? 'both');
            if ( !in_array( $applies_to, ['interior', 'exterior', 'both'], true ) ) {
                $applies_to = 'both';
            }
            $normalized_components[$comp_id] = [
                'label'      => (string) ($comp['label'] ?? ''),
                'text'       => (string) ($comp['text'] ?? ''),
                'applies_to' => $applies_to,
            ];
        }
        $category['scope_components'] = $normalized_components;
        return $category;
    }

    /**
     * Starter library of "scope creep" terms, words that, in plain English,
     * feel obvious to the person who wrote the quote but are genuinely
     * ambiguous to the customer reading it (e.g. "caulk baseboards" reads to
     * a homeowner as "caulk everywhere a baseboard touches something," which
     * can include the floor gap, work Mike never intended to include).
     * Each entry pairs a term to watch for with suggested disclaimer wording
     * a user can insert into a quote's Exclusions field with one click.
     * Purely a starting point, fully editable from Business Settings.
     */
    function paintops_default_scope_terms() : array {
        return [
            1  => [
                'term'       => 'caulk',
                'disclaimer' => "Caulking includes wall-to-trim and trim-to-trim joints only. Does not include baseboard-to-floor gaps, tub/tile caulking, or exterior siding joints unless listed separately.",
            ],
            2  => [
                'term'       => 'patch',
                'disclaimer' => "Patching covers minor surface imperfections and nail holes up to 2 inches. Larger repairs, water damage, or structural drywall work are billed separately.",
            ],
            3  => [
                'term'       => 'pressure wash',
                'disclaimer' => "Pressure washing covers accessible exterior surfaces at ground level. Does not include second-story reach, roof, or gutters unless listed separately.",
            ],
            4  => [
                'term'       => 'trim',
                'disclaimer' => "Trim work refers to painting existing trim as-is. Does not include repairing or replacing damaged or rotted trim.",
            ],
            5  => [
                'term'       => 'ceiling',
                'disclaimer' => "Ceiling painting does not include popcorn texture removal, repair, or smoothing unless listed separately.",
            ],
            6  => [
                'term'       => 'wallpaper',
                'disclaimer' => "Wallpaper removal is billed per the condition found on-site. Wall repair after removal (skim coat, sanding) is billed separately if needed.",
            ],
            7  => [
                'term'       => 'cabinet',
                'disclaimer' => "Cabinet painting covers doors, drawer fronts, and face frames only. Does not include cabinet interiors, shelving, or hardware replacement unless listed separately.",
            ],
            8  => [
                'term'       => 'stain',
                'disclaimer' => "Staining does not include wood repair, replacement of damaged boards, or pressure washing unless listed separately.",
            ],
            9  => [
                'term'       => 'color change',
                'disclaimer' => "One color per area as specified. Additional color changes requested after work begins may incur an added charge.",
            ],
            10 => [
                'term'       => 'touch-up',
                'disclaimer' => "Touch-ups are included for 7 days after project completion, limited to issues from our workmanship, not new damage.",
            ],
            11 => [
                'term'       => 'move furniture',
                'disclaimer' => "We move light furniture away from walls. Heavy furniture, electronics, and wall art removal/rehanging are the customer's responsibility unless listed separately.",
            ],
            12 => [
                'term'       => 'mildew',
                'disclaimer' => "Treatment covers surface mildew only. Active mold growth requires remediation by a licensed specialist before painting begins.",
            ],
            13 => [
                'term'       => 'stucco',
                'disclaimer' => "Stucco patch repair covers minor cracks and small areas only; texture and color match may vary slightly from surrounding stucco. Large cracks, structural repair, or full re-stucco sections are billed separately.",
            ],
            14 => [
                'term'       => 'crack',
                'disclaimer' => "Crack repair covers hairline surface cracks only. Cracks indicating structural movement or foundation settling are excluded and may require a specialist referral.",
            ],
            15 => [
                'term'       => 'peeling',
                'disclaimer' => "Removal of peeling paint is limited to loose material found during prep. Heavy peeling caused by moisture intrusion or substrate failure may require additional repair not included in this scope.",
            ],
            16 => [
                'term'       => 'lead',
                'disclaimer' => "Homes built before 1978 may contain lead-based paint. Per EPA RRP rules, lead-safe work practices apply and may involve additional containment, testing, and disposal costs not included unless listed separately.",
            ],
            17 => [
                'term'       => 'sheen',
                'disclaimer' => "Sheen will be matched as closely as possible to the product line specified. Slight variation may occur due to surface texture, lighting, or differences in the previous coating, and is not treated as a defect.",
            ],
        ];
    }

    /**
     * Starter library of "situational" clauses, unlike the scope terms
     * above, these aren't triggered by matching specific wording in the Job
     * Description. They apply (or don't) based on the situation of the job
     * itself, so they're offered as an always-available checklist instead of
     * a text scan. Any clause with default_on = true is pre-filled into the
     * Exclusions field automatically on every new job, for
     * businesses that always want a given clause included, so staff don't
     * have to remember to check it every time.
     */
    function paintops_default_scope_situational() : array {
        return [
            1 => [
                'label'      => 'Pets',
                'disclaimer' => "Customer is responsible for securing pets during work hours. We are not liable for pets that escape, are stressed, or ingest paint/materials if left unsecured in work areas.",
                'default_on' => true,
            ],
            2 => [
                'label'      => 'Other trades on site',
                'disclaimer' => "If other trades are working in the same area during our scheduled time, delays or added costs may result from scheduling conflicts or protecting completed work. Damage caused by other trades to our completed work is not covered.",
                'default_on' => true,
            ],
            3 => [
                'label'      => 'Children/others present',
                'disclaimer' => "For safety, we ask that children and non-essential household members stay out of active work areas during painting hours.",
                'default_on' => false,
            ],
            4 => [
                'label'      => 'Weather (exterior work)',
                'disclaimer' => "Exterior work is weather-dependent. Rain, high wind, or temperatures outside the paint manufacturer's application range may delay the schedule at no additional cost.",
                'default_on' => false,
            ],
            5 => [
                'label'      => 'Property access',
                'disclaimer' => "Customer will provide clear access to all work areas (gate codes, keys, alarm instructions) by the scheduled start time. Delays caused by lack of access may push back the completion date.",
                'default_on' => false,
            ],
            6 => [
                'label'      => 'HOA approval',
                'disclaimer' => "Customer is responsible for obtaining any required HOA or community approval for color/work prior to the scheduled start date. Delays or rework caused by pending or denied HOA approval are not covered.",
                'default_on' => false,
            ],
            7 => [
                'label'      => 'Parking on site',
                'disclaimer' => "Customer will provide reasonably close parking for crew vehicles and equipment during work hours. Paid parking, permits, or citations resulting from lack of available parking are the customer's responsibility.",
                'default_on' => false,
            ],
        ];
    }

    /**
     * Read the scope-terms library (falls back to the starter list above if
     * nothing has been saved/edited yet).
     */
    function paintops_scope_terms() : array {
        $saved = get_option( 'paintops_scope_terms', null );
        return ( !is_array( $saved ) || empty( $saved ) ? paintops_default_scope_terms() : $saved );
    }

    /**
     * Read the situational-clauses library.
     */
    function paintops_scope_situational_clauses() : array {
        $saved = get_option( 'paintops_scope_situational', null );
        return ( !is_array( $saved ) || empty( $saved ) ? paintops_default_scope_situational() : $saved );
    }

    /**
     * One-time migration: Job Categories used to carry their own "Quote note"
     * (a fixed reminder auto-injected into every quote/email for that
     * category, e.g. "paint colors must be confirmed"). That's now redundant
     * with Situational Clauses, same idea (a reusable reminder, optionally
     * on by default) but editable per quote instead of silently baked in.
     *
     * Runs once per site (guarded by the paintops_quote_notes_migrated
     * option, set at the end) as part of paintops_install() so it fires
     * automatically the first time a site upgrades to the version that
     * removed the old field, any category quote_note still holding text
     * gets carried forward as a new default-on situational clause instead of
     * just silently vanishing.
     */
    function paintops_migrate_category_quote_notes_to_situational() : void {
        if ( get_option( 'paintops_quote_notes_migrated', false ) ) {
            return;
        }
        $categories = paintops_job_categories();
        $clauses = paintops_scope_situational_clauses();
        $next_id = ( empty( $clauses ) ? 1 : max( array_keys( $clauses ) ) + 1 );
        $migrated_any = false;
        foreach ( $categories as $cat_id => $cat ) {
            $note = trim( (string) ($cat['quote_note'] ?? '') );
            if ( $note === '' ) {
                continue;
            }
            // Both starter categories shipped the same note so migrating each one made two identical clauses that then printed the same paragraph twice on every quote.
            $note_already_exists = false;
            foreach ( $clauses as $existing_clause ) {
                if ( trim( (string) ($existing_clause['disclaimer'] ?? '') ) === $note ) {
                    $note_already_exists = true;
                    break;
                }
            }
            if ( $note_already_exists ) {
                $categories[$cat_id]['quote_note'] = '';
                $migrated_any = true;
                continue;
            }
            $clauses[$next_id] = [
                'label'      => ($cat['name'] ?? 'Category') . ' Note',
                'disclaimer' => $note,
                'default_on' => true,
            ];
            $next_id++;
            $categories[$cat_id]['quote_note'] = '';
            $migrated_any = true;
        }
        if ( $migrated_any ) {
            update_option( 'paintops_scope_situational', $clauses );
            update_option( 'paintops_job_categories', $categories );
        }
        update_option( 'paintops_quote_notes_migrated', 1 );
    }

    // One-time cleanup for sites that already ran the migration above before it skipped duplicate texts and so carry two identical clauses that print the same paragraph twice on every new quote.
    function paintops_dedupe_situational_clauses() : void {
        if ( get_option( 'paintops_situational_deduped', false ) ) {
            return;
        }
        $saved = get_option( 'paintops_scope_situational', null );
        if ( is_array( $saved ) && !empty( $saved ) ) {
            $seen_texts = [];
            $removed_any = false;
            foreach ( $saved as $clause_key => $clause ) {
                $clause_text = trim( (string) ($clause['disclaimer'] ?? '') );
                if ( $clause_text !== '' && isset( $seen_texts[$clause_text] ) ) {
                    unset($saved[$clause_key]);
                    $removed_any = true;
                    continue;
                }
                $seen_texts[$clause_text] = true;
            }
            if ( $removed_any ) {
                update_option( 'paintops_scope_situational', $saved );
            }
        }
        paintops_scrub_repeated_scope_paragraphs();
        update_option( 'paintops_situational_deduped', 1 );
    }

    // Collapses back-to-back identical paragraphs in saved scope notes since records created while the duplicate clause existed have the doubled text baked into the row itself.
    function paintops_collapse_repeated_paragraphs(  string $text  ) : string {
        $normalized = str_replace( ["\r\n", "\r"], "\n", $text );
        $paragraphs = preg_split( '/\\n{2,}/', $normalized );
        $kept = [];
        foreach ( $paragraphs as $paragraph ) {
            $trimmed = trim( $paragraph );
            if ( $trimmed !== '' && !empty( $kept ) && trim( end( $kept ) ) === $trimmed ) {
                continue;
            }
            $kept[] = $paragraph;
        }
        return implode( "\n\n", $kept );
    }

    // One-time row scrub run with the clause dedupe above and covered by the same option guard.
    function paintops_scrub_repeated_scope_paragraphs() : void {
        global $wpdb;
        $scrub_targets = [[
            'table' => paintops_table( 'jobs' ),
            'pk'    => 'id',
        ], [
            'table' => paintops_table( 'estimates' ),
            'pk'    => 'estID',
        ]];
        foreach ( $scrub_targets as $target ) {
            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT %i AS pk, scope_notes FROM %i WHERE scope_notes IS NOT NULL AND scope_notes != ''", $target['pk'], $target['table'] ) );
            if ( !$rows ) {
                continue;
            }
            foreach ( $rows as $row ) {
                $cleaned = paintops_collapse_repeated_paragraphs( (string) $row->scope_notes );
                if ( $cleaned !== (string) $row->scope_notes ) {
                    $wpdb->update(
                        $target['table'],
                        [
                            'scope_notes' => $cleaned,
                        ],
                        [
                            $target['pk'] => $row->pk,
                        ],
                        ['%s'],
                        ['%d']
                    );
                }
            }
        }
    }

    /**
     * One-time seed: Scope Components used to not exist at all, so any site
     * that installed PaintOps before this shipped already has its own
     * "Residential Painting"/"Commercial Painting" categories saved to the
     * paintops_job_categories option with an empty scope_components list,
     * paintops_seed_default_options() only ever writes that option once, on
     * a brand-new install, so those sites would never pick up the new
     * starter checklist on their own. This runs once (guarded by the
     * paintops_scope_components_seeded option) and copies the starter
     * checklist from paintops_default_job_categories() into any *existing*
     * category that shares the same numeric ID and doesn't already have
     * components of its own, so a business's first category (usually the
     * old #1/#2) gets a real starting checklist instead of staying empty
     * forever, while anything already customized here is left untouched.
     */
    function paintops_migrate_default_category_scope_components() : void {
        if ( get_option( 'paintops_scope_components_seeded', false ) ) {
            return;
        }
        $categories = get_option( 'paintops_job_categories', null );
        if ( is_array( $categories ) ) {
            $defaults = paintops_default_job_categories();
            $changed = false;
            foreach ( $categories as $cat_id => $cat ) {
                $existing_components = ( is_array( $cat ) ? $cat['scope_components'] ?? [] : [] );
                if ( !empty( $existing_components ) ) {
                    continue;
                    // already has its own checklist, don't touch it
                }
                if ( !empty( $defaults[$cat_id]['scope_components'] ) ) {
                    $categories[$cat_id]['scope_components'] = $defaults[$cat_id]['scope_components'];
                    $changed = true;
                }
            }
            if ( $changed ) {
                update_option( 'paintops_job_categories', $categories );
            }
        }
        update_option( 'paintops_scope_components_seeded', 1 );
    }

    /**
     * Starter Price List, a plain reference sheet (NOT another calculator;
     * Mike specifically wants to stay lightweight here), seeded from US
     * national-average painting cost research (mid-2026, various contractor
     * cost-guide sources) so there's something useful on day one. These are
     * deliberately rough, single numbers picked from the middle of a wide
     * published range, actual cost of living/labor rates vary a lot by
     * region, so every entry is meant to be recalibrated to a business's own
     * market from Business Settings, not treated as gospel. Each entry notes
     * the published range it was drawn from, so a user can judge for
     * themselves whether their market runs high or low against it.
     */
    function paintops_default_price_list() : array {
        return [
            1  => [
                'label' => 'Baseboards',
                'unit'  => 'linear_ft',
                'price' => 2.5,
                'note'  => 'US avg range ~$1-$5/linear ft',
            ],
            2  => [
                'label' => 'Crown Molding',
                'unit'  => 'linear_ft',
                'price' => 2.5,
                'note'  => 'US avg range ~$1-$5/linear ft',
            ],
            3  => [
                'label' => 'Fence (Picket/Standard)',
                'unit'  => 'linear_ft',
                'price' => 3.5,
                'note'  => 'US avg range ~$2-$5/linear ft',
            ],
            4  => [
                'label' => 'Fence (Privacy)',
                'unit'  => 'linear_ft',
                'price' => 7.0,
                'note'  => 'US avg range ~$4-$10/linear ft',
            ],
            5  => [
                'label' => 'Kitchen Cabinets',
                'unit'  => 'linear_ft',
                'price' => 50.0,
                'note'  => 'US avg range ~$30-$70/linear ft',
            ],
            6  => [
                'label' => 'Exterior House Painting',
                'unit'  => 'sqft',
                'price' => 3.0,
                'note'  => 'US avg range ~$1.50-$4.50/sqft of wall area',
            ],
            7  => [
                'label' => 'Interior Walls',
                'unit'  => 'sqft',
                'price' => 3.5,
                'note'  => 'US avg range ~$2-$6/sqft',
            ],
            8  => [
                'label' => 'Ceiling',
                'unit'  => 'sqft',
                'price' => 2.0,
                'note'  => 'US avg range ~$0.50-$5/sqft',
            ],
            9  => [
                'label' => 'Interior Door (incl. frame)',
                'unit'  => 'flat',
                'price' => 110,
                'note'  => 'US avg range ~$75-$150 each',
            ],
            10 => [
                'label' => 'Exterior/Entry Door',
                'unit'  => 'flat',
                'price' => 200,
                'note'  => 'US avg range ~$125-$225 each',
            ],
            11 => [
                'label' => 'Bedroom, 10x12 (walls only)',
                'unit'  => 'flat',
                'price' => 500,
                'note'  => 'US avg range ~$300-$750',
            ],
            12 => [
                'label' => 'Bathroom, Half Bath',
                'unit'  => 'flat',
                'price' => 275,
                'note'  => 'US avg range ~$150-$400',
            ],
            13 => [
                'label' => 'Bathroom, Primary/Full',
                'unit'  => 'flat',
                'price' => 700,
                'note'  => 'US avg range ~$350-$1,500',
            ],
            14 => [
                'label' => 'Living Room (avg size, w/ trim)',
                'unit'  => 'flat',
                'price' => 700,
                'note'  => 'US avg range ~$600-$800',
            ],
        ];
    }

    /**
     * Read the Price List (falls back to the starter list above).
     */
    function paintops_price_list() : array {
        $saved = get_option( 'paintops_price_list', null );
        return ( !is_array( $saved ) || empty( $saved ) ? paintops_default_price_list() : $saved );
    }

    /**
     * Reusable Documents library, a small set of standing PDFs (insurance
     * certificate, standard contract terms, W-9, etc.) uploaded once under
     * PaintOps > Business, then offered as one-click attachments on the
     * "Email This Quote" form (estimates.php) instead of digging up and
     * re-attaching the same file by hand every time. Deliberately empty by
     * default, there's no generic starter set that makes sense here the way
     * there is for Price List/Scope Terms, since these are specific to each
     * business's own paperwork.
     *
     * Stored as a plain option (not a DB table) to match every other small
     * admin-managed library in this file (Price List, Scope Terms,
     * Situational Clauses), this isn't high-volume per-job data, just a
     * short curated list. Files themselves are handled with the same
     * wp_handle_upload() pattern used for job photos/receipts (see
     * includes/job-photos.php) rather than the WP Media Library, so nothing
     * from this ends up mixed into the site's own media grid.
     */
    function paintops_documents_library() : array {
        $saved = get_option( 'paintops_documents', [] );
        return ( is_array( $saved ) ? $saved : [] );
    }

    /**
     * Follow-Up message templates, editable email subject/body and SMS
     * body used when nudging a customer who hasn't accepted a quote yet.
     * Stored as a single option, same pattern as the other small libraries
     * in this file. Supports placeholders: {customer_name}, {business_name},
     * {quote_link}.
     */
    function paintops_followup_template() : array {
        $defaults = [
            'email_subject' => 'Following up on your quote',
            'email_body'    => "Hi {customer_name},\n\nJust following up on the quote we sent over, wanted to make sure it didn't get lost in your inbox.\n\nYou can view and accept it here:\n{quote_link}\n\nLet me know if you have any questions.\n\nBest,\n{business_name}",
            'sms_body'      => "Hi {customer_name}, just following up on the quote from {business_name}. You can view it here: {quote_link}",
        ];
        $saved = get_option( 'paintops_followup_template', [] );
        if ( !is_array( $saved ) ) {
            $saved = [];
        }
        return array_merge( $defaults, $saved );
    }

    /**
     * Replaces {customer_name}/{business_name}/{quote_link} placeholders in
     * a Follow-Up template string with real values for a given job/estimate.
     */
    function paintops_render_followup_placeholders(  string $text, $job, $estimate  ) : string {
        $company = paintops_company_profile();
        $quote_link = ( function_exists( 'paintops_customer_quote_url' ) ? paintops_customer_quote_url( (int) $estimate->estID ) : '' );
        $replacements = [
            '{customer_name}' => trim( ($job->custFirst ?? '') . ' ' . ($job->custLast ?? '') ),
            '{business_name}' => $company['name'] ?? get_bloginfo( 'name' ),
            '{quote_link}'    => $quote_link,
        ];
        return str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
    }

    /**
     * Append one row to the communications log. Fire-and-forget: a logging
     * failure must never break the send that just succeeded, so no return
     * value and no error surfacing. Call AFTER wp_mail() reports success.
     */
    function paintops_log_communication(  array $args  ) : void {
        global $wpdb;
        $wpdb->insert( paintops_table( 'comm_log' ), [
            'job_id'      => (int) ($args['job_id'] ?? 0),
            'estimate_id' => (int) ($args['estimate_id'] ?? 0),
            'invoice_id'  => (int) ($args['invoice_id'] ?? 0),
            'comm_type'   => sanitize_key( $args['comm_type'] ?? '' ),
            'recipient'   => sanitize_text_field( (string) ($args['recipient'] ?? '') ),
            'subject'     => mb_substr( sanitize_text_field( (string) ($args['subject'] ?? '') ), 0, 255 ),
            'sent_by'     => get_current_user_id(),
            'sent_at'     => current_time( 'mysql' ),
        ], [
            '%d',
            '%d',
            '%d',
            '%s',
            '%s',
            '%s',
            '%d',
            '%s'
        ] );
    }

    /** Newest-first communications for one job (capped, the log is append-only). */
    function paintops_get_job_communications(  int $job_id  ) : array {
        global $wpdb;
        if ( $job_id <= 0 ) {
            return [];
        }
        return ( $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE job_id = %d ORDER BY sent_at DESC, id DESC LIMIT 50", paintops_table( 'comm_log' ), $job_id ) ) ?: [] );
    }

    /** Human label for a comm_type slug. */
    function paintops_comm_type_label(  string $type  ) : string {
        $labels = [
            'quote_email'    => 'Quote emailed',
            'followup_email' => 'Follow-up emailed',
            'invoice_email'  => 'Invoice emailed',
            'reminder_email' => 'Payment reminder emailed',
            'review_request' => 'Review request emailed',
            'quote_text'     => 'Texted',
            'call_click'     => 'Called',
            'text_click'     => 'Texted',
            'email_click'    => 'Emailed',
        ];
        return $labels[$type] ?? ucwords( str_replace( '_', ' ', $type ) );
    }

    /**
     * Logs a "Text Quote/Estimate/Bid" tap (Mike 2026-07-23: "is there a
     * way to show if a quote has been emailed or texted?"). The Text link
     * is an sms: deep link into the staff phone's own Messages app, so
     * delivery can never be confirmed; recording that the compose window
     * was opened is the best available signal, and the log subject says so.
     * Fired by navigator.sendBeacon from the Text link in estimates.php
     * (best-effort, never delays the sms: navigation). Surfaces on the
     * request card's "Texted <date>" line, the job view's Communications
     * drawer, and the Not Won drawer's History.
     */
    function paintops_ajax_log_quote_text() : void {
        if ( !check_ajax_referer( 'paintops_log_quote_text', 'nonce', false ) ) {
            wp_send_json_error( [], 403 );
        }
        $can = function_exists( 'paintops_user_can_manage' ) && paintops_user_can_manage() || function_exists( 'paintops_user_can_create_quote' ) && paintops_user_can_create_quote();
        if ( !$can ) {
            wp_send_json_error( [], 403 );
        }
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        $lqt_job = (int) ($_POST['job_id'] ?? 0);
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        $lqt_est = (int) ($_POST['estimate_id'] ?? 0);
        if ( $lqt_job <= 0 ) {
            wp_send_json_error( [], 400 );
        }
        $lqt_doc = sanitize_key( wp_unslash( $_POST['doc'] ?? 'quote' ) );
        if ( !in_array( $lqt_doc, ['quote', 'estimate', 'bid'], true ) ) {
            $lqt_doc = 'quote';
        }
        paintops_log_communication( [
            'job_id'      => $lqt_job,
            'estimate_id' => $lqt_est,
            'comm_type'   => 'quote_text',
            'recipient'   => sanitize_text_field( wp_unslash( $_POST['recipient'] ?? '' ) ),
            'subject'     => ucfirst( $lqt_doc ) . ' #' . $lqt_est . ' text link tapped (compose opened; delivery is not trackable)',
        ] );
        wp_send_json_success();
    }

    add_action( 'wp_ajax_paintops_log_quote_text', 'paintops_ajax_log_quote_text' );
    /**
     * Generic customer-contact tap logger (Mike 2026-07-23: every Call /
     * Text / Email link that reaches a CUSTOMER should land in the
     * communications log, not just the quote email send). Fired by the
     * app-wide delegated listener in manage-jobs.php's nav script for any
     * element carrying data-po-comm/data-po-job (Not Won drawer links, the
     * job page's customer Call/Text reveal, the job actions menu's Call
     * Customer / Text Customer, the Follow-Up nudge text). tel:/sms:/
     * mailto: links only OPEN the phone or mail app, so what gets logged
     * is the tap, and each subject says exactly that. Team-member Call/
     * Text chips are deliberately NOT tagged, coworker contact is not
     * customer-communication history.
     */
    function paintops_ajax_log_contact() : void {
        if ( !check_ajax_referer( 'paintops_log_contact', 'nonce', false ) ) {
            wp_send_json_error( [], 403 );
        }
        $lc_user = wp_get_current_user();
        $lc_ok = function_exists( 'paintops_user_can_manage' ) && paintops_user_can_manage() || function_exists( 'paintops_worker_role_slug' ) && in_array( paintops_worker_role_slug(), (array) $lc_user->roles, true );
        if ( !$lc_ok ) {
            wp_send_json_error( [], 403 );
        }
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        $lc_job = (int) ($_POST['job_id'] ?? 0);
        if ( $lc_job <= 0 ) {
            wp_send_json_error( [], 400 );
        }
        $lc_type = sanitize_key( wp_unslash( $_POST['contact_type'] ?? '' ) );
        $lc_map = [
            'call'  => 'call_click',
            'text'  => 'text_click',
            'email' => 'email_click',
        ];
        if ( !isset( $lc_map[$lc_type] ) ) {
            wp_send_json_error( [], 400 );
        }
        $lc_src = sanitize_text_field( wp_unslash( $_POST['src'] ?? '' ) );
        $lc_caveats = [
            'call'  => 'link tapped (the call itself is not tracked)',
            'text'  => 'link tapped (compose opened; delivery is not trackable)',
            'email' => 'link tapped (opens their mail app; the send itself is not tracked)',
        ];
        paintops_log_communication( [
            'job_id'    => $lc_job,
            'comm_type' => $lc_map[$lc_type],
            'recipient' => sanitize_text_field( wp_unslash( $_POST['recipient'] ?? '' ) ),
            'subject'   => ucfirst( $lc_type ) . ' ' . $lc_caveats[$lc_type] . (( $lc_src !== '' ? ', from the ' . $lc_src : '' )),
        ] );
        wp_send_json_success();
    }

    add_action( 'wp_ajax_paintops_log_contact', 'paintops_ajax_log_contact' );
    /**
     * Renders the "attach a reusable document" checkbox list used on the
     * Email This Quote form (estimates.php). Kept here alongside the rest
     * of the Documents library so the checklist markup and the underlying
     * data stay in one place. Returns '' (renders nothing) when no
     * documents have been uploaded yet.
     */
    function paintops_document_checklist_field() : string {
        $documents = paintops_documents_library();
        if ( empty( $documents ) ) {
            return '';
        }
        $rows = '';
        foreach ( $documents as $id => $doc ) {
            $label = ( ($doc['label'] ?? '') !== '' ? $doc['label'] : '(untitled document)' );
            $rows .= '<label style="display:block;font-weight:normal;margin:4px 0;"><input type="checkbox" name="send_doc_ids[]" value="' . esc_attr( (string) $id ) . '"> ' . esc_html( $label ) . '</label>';
        }
        return '<p><label>Attach reusable documents (optional)</label>' . $rows . '</p>';
    }

    /**
     * Human-readable unit labels for the Price List, shared by the Business
     * Settings editor and the quick-reference popup on the quote screen.
     *
     * Metric options (linear_m/sqm) are always available here regardless of
     * Company Profile > Measurement Units, same "never restrict input"
     * principle as Country/Currency, but which pair leads the dropdown
     * (and so which a brand-new row defaults toward) follows that setting,
     * so a metric business isn't stuck scrolling past ft options every time.
     */
    function paintops_price_list_units() : array {
        $imperial = [
            'linear_ft' => 'per linear ft',
            'sqft'      => 'per sq ft',
        ];
        $metric = [
            'linear_m' => 'per linear meter',
            'sqm'      => 'per sq meter',
        ];
        $ordered = ( paintops_uses_metric() ? $metric + $imperial : $imperial + $metric );
        $ordered['flat'] = 'flat';
        return $ordered;
    }

    /**
     * How a job's deposit should be handled, set per-job so office staff can
     * make the call before any Stripe request is ever sent for that job. Not
     * every customer pays a deposit online: some pay cash/check, and repeat
     * trusted customers may have the deposit waived entirely. Whatever a job
     * is set to here is checked before PaintOps ever calls the Stripe API for
     * that job.
     */
    function paintops_payment_plan_options() : array {
        // Labels adapt to whether online card payments are available in this
        // build and configuration. The key set stays identical either way,
        // stored values always resolve to a label.
        $plan_online = function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready();
        return [
            'deposit_online'  => 'Card deposit online' . (( $plan_online ? ' (default)' : ' (requires Stripe)' )),
            'deposit_offline' => 'Deposit due, but paid by cash/check (no online link sent)' . (( $plan_online ? '' : ' (default)' )),
            'no_deposit'      => 'No deposit required (trusted repeat customer)',
        ];
    }

    /**
     * Default payment plan for NEW records: online card deposit when online
     * payments are actually available, offline cash/check deposit otherwise.
     * Defaulting a site into a plan it can't use would be misleading.
     */
    function paintops_default_payment_plan() : string {
        return ( function_exists( 'paintops_online_payments_ready' ) && paintops_online_payments_ready() ? 'deposit_online' : 'deposit_offline' );
    }

    /**
     * Sanitize a submitted payment_plan value against the known options above,
     * falling back to the default if it's missing or not recognized.
     */
    function paintops_sanitize_payment_plan(  string $value  ) : string {
        $value = sanitize_key( $value );
        $allowed = array_keys( paintops_payment_plan_options() );
        return ( in_array( $value, $allowed, true ) ? $value : paintops_default_payment_plan() );
    }

    function paintops_default_pricing_rules() : array {
        return [
            'labor_rate_per_day'     => 500,
            'markup_percent'         => 20,
            'deposit_high_threshold' => 4000,
            'deposit_high_percent'   => 30,
            'deposit_mid_threshold'  => 1000,
            'deposit_mid_percent'    => 35,
            'card_fee_percent'       => 3.0,
            'tax_label'              => 'Sales Tax',
            'tax_percent'            => 0,
        ];
    }

    /**
     * Business-level Quote Terms & Conditions (2026-07-19, v1.0.105). Empty
     * string (or never saved) = no terms render anywhere. Edited under
     * PaintOps > Business Settings > Company Profile; consumed by the public
     * quote accept page and the printable quote (customer-payments.php).
     */
    function paintops_quote_terms() : string {
        $terms = get_option( 'paintops_quote_terms', '' );
        return ( is_string( $terms ) ? trim( $terms ) : '' );
    }

    // Days a quote stays valid, from Business Settings; 0 disables expiration.
    function paintops_quote_valid_days() : int {
        return max( 0, (int) get_option( 'paintops_quote_valid_days', 30 ) );
    }

    // Pseudo-local end-of-day expiration timestamp for an estimate, null when disabled.
    function paintops_quote_expiration_ts(  $estimate  ) : ?int {
        $days = paintops_quote_valid_days();
        if ( $days <= 0 || empty( $estimate->createdOn ) ) {
            return null;
        }
        $created = strtotime( (string) $estimate->createdOn );
        if ( !$created ) {
            return null;
        }
        $exp = $created + $days * DAY_IN_SECONDS;
        return $exp - $exp % DAY_IN_SECONDS + (DAY_IN_SECONDS - 1);
    }

    // True when an unaccepted estimate is past its validity window.
    function paintops_quote_is_expired(  $estimate  ) : bool {
        if ( !empty( $estimate->accepted ) ) {
            return false;
        }
        $exp = paintops_quote_expiration_ts( $estimate );
        return null !== $exp && current_time( 'timestamp' ) > $exp;
    }

    // The printed valid-until line; '' when disabled or already accepted.
    function paintops_quote_validity_line(  $estimate, string $quote_word = 'quote'  ) : string {
        if ( !empty( $estimate->accepted ) ) {
            return '';
        }
        $exp = paintops_quote_expiration_ts( $estimate );
        if ( null === $exp ) {
            return '';
        }
        return 'This ' . $quote_word . ' is valid until ' . gmdate( 'F j, Y', $exp ) . '.';
    }

    /**
     * Starter contract text seeded into the settings textarea when terms have
     * never been saved. NOT live until the owner edits and saves it. Kept
     * deliberately short, plain-language, and placeholder-marked; the
     * settings UI tells the owner to have an attorney review.
     */
    function paintops_quote_terms_starter() : string {
        return "1. Scope. The work covered by this document is limited to what is described in it. Anything not listed (including items in Exclusions) is not included.\n\n" . "2. Changes. Changes or additions requested after acceptance will be priced separately and confirmed in writing before the extra work begins.\n\n" . "3. Scheduling & weather. Start and completion dates are good-faith estimates. Exterior work depends on weather; delays from weather, access, or material availability extend the schedule, not the price.\n\n" . "4. Site conditions. Pricing assumes surfaces in reasonably sound condition. Hidden damage discovered after work begins (rot, prior coating failure, structural issues) will be brought to your attention with options before proceeding.\n\n" . "5. Cancellation. You may cancel before materials are purchased or work begins for a full refund of any deposit, less costs already incurred.\n\n" . "6. Disputes. We'll always try to resolve concerns directly first. Unresolved disputes are governed by the laws of [your state], venue [your county].";
    }

    /**
     * Read the company profile, merging in defaults for any missing keys
     * (keeps this safe even if the option is only partially saved).
     */
    function paintops_company_profile() : array {
        $saved = get_option( 'paintops_company_profile', [] );
        if ( !is_array( $saved ) ) {
            $saved = [];
        }
        return array_merge( paintops_default_company_profile(), $saved );
    }

    /**
     * Pull City/State/Zip out of the Business Settings "Address Line 2 (City,
     * State ZIP)" field, e.g. "Englewood, FL 34223", there's no separate
     * city/state/zip field in Company Profile, just this one combined line, so
     * this is a best-effort parse rather than a real data source. Used only
     * to make the New Request form's City/State/Zip placeholder examples show
     * this business's own city/state/zip instead of a generic made-up one.
     * Any part that can't be parsed out just comes back blank, callers fall
     * back to a generic example rather than leaving the placeholder empty.
     */
    if ( !function_exists( 'paintops_business_address_parts' ) ) {
        function paintops_business_address_parts() : array {
            $parts = [
                'city'  => '',
                'state' => '',
                'zip'   => '',
            ];
            $address2 = trim( (string) (paintops_company_profile()['address2'] ?? '') );
            if ( $address2 === '' ) {
                return $parts;
            }
            if ( preg_match( '/^(.*),\\s*([A-Za-z]{2})\\s+(\\d{5})(?:-\\d{4})?\\s*$/', $address2, $matches ) ) {
                $parts['city'] = trim( $matches[1] );
                $parts['state'] = strtoupper( $matches[2] );
                $parts['zip'] = $matches[3];
            }
            return $parts;
        }

    }
    /**
     * Country options for the job/customer address forms, one setting under
     * Company Profile (not a per-job or per-customer choice, since a single
     * install is one business, and a painting contractor's jobs are local to
     * wherever that business operates). Changing this only swaps labels,
     * example placeholders, and how the State/Province field behaves (max
     * length, forced uppercase), the underlying custServiceState/
     * custServiceZip columns stay plain free text either way, so nothing here
     * ever blocks an address that doesn't fit the pattern.
     */
    if ( !function_exists( 'paintops_address_format_options' ) ) {
        function paintops_address_format_options() : array {
            return [
                'US'    => [
                    'label'           => 'United States',
                    'state_label'     => 'State',
                    'state_maxlength' => 2,
                    'state_uppercase' => true,
                    'zip_label'       => 'Zip Code',
                    'example_city'    => 'Columbus',
                    'example_state'   => 'OH',
                    'example_zip'     => '43215',
                ],
                'CA'    => [
                    'label'           => 'Canada',
                    'state_label'     => 'Province',
                    'state_maxlength' => 2,
                    'state_uppercase' => true,
                    'zip_label'       => 'Postal Code',
                    'example_city'    => 'Toronto',
                    'example_state'   => 'ON',
                    'example_zip'     => 'M5V 2T6',
                ],
                'GB'    => [
                    'label'           => 'United Kingdom',
                    'state_label'     => 'County (Optional)',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Postcode',
                    'example_city'    => 'London',
                    'example_state'   => '',
                    'example_zip'     => 'SW1A 1AA',
                ],
                'AU'    => [
                    'label'           => 'Australia',
                    'state_label'     => 'State',
                    'state_maxlength' => 0,
                    'state_uppercase' => true,
                    'zip_label'       => 'Postcode',
                    'example_city'    => 'Sydney',
                    'example_state'   => 'NSW',
                    'example_zip'     => '2000',
                ],
                'NZ'    => [
                    'label'           => 'New Zealand',
                    'state_label'     => 'Region (Optional)',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Postcode',
                    'example_city'    => 'Auckland',
                    'example_state'   => '',
                    'example_zip'     => '1010',
                ],
                'IE'    => [
                    'label'           => 'Ireland',
                    'state_label'     => 'County (Optional)',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Eircode',
                    'example_city'    => 'Dublin',
                    'example_state'   => '',
                    'example_zip'     => 'D02 AF30',
                ],
                'ZA'    => [
                    'label'           => 'South Africa',
                    'state_label'     => 'Province (Optional)',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Postal Code',
                    'example_city'    => 'Cape Town',
                    'example_state'   => 'Western Cape',
                    'example_zip'     => '8001',
                ],
                'DE'    => [
                    'label'           => 'Germany',
                    'state_label'     => 'State / Bundesland (Optional)',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Postal Code (PLZ)',
                    'example_city'    => 'Berlin',
                    'example_state'   => '',
                    'example_zip'     => '10115',
                ],
                'OTHER' => [
                    'label'           => 'Other / International',
                    'state_label'     => 'State / Province / Region',
                    'state_maxlength' => 0,
                    'state_uppercase' => false,
                    'zip_label'       => 'Zip / Postal Code',
                    'example_city'    => 'City',
                    'example_state'   => 'State/Province',
                    'example_zip'     => 'Postal Code',
                ],
            ];
        }

    }
    /**
     * The address format for this business's own country (Company Profile >
     * Country). Falls back to 'US' for any unrecognized/missing value so an
     * install never ends up with blank labels.
     */
    if ( !function_exists( 'paintops_address_format' ) ) {
        function paintops_address_format() : array {
            $options = paintops_address_format_options();
            $country = (string) (paintops_company_profile()['country'] ?? 'US');
            return $options[$country] ?? $options['US'];
        }

    }
    /**
     * Currency options for Company Profile > Currency. Deliberately a separate
     * setting from Country rather than derived from it, a business can
     * operate in a country and bill in a different currency, and forcing a
     * 1:1 mapping would just mean unpicking it again later. Covers the
     * currencies needed for the countries in paintops_address_format_options()
     * above, plus a couple of common extras.
     */
    if ( !function_exists( 'paintops_supported_currencies' ) ) {
        function paintops_supported_currencies() : array {
            return [
                'USD' => [
                    'label'  => 'US Dollar (USD)',
                    'symbol' => '$',
                ],
                'CAD' => [
                    'label'  => 'Canadian Dollar (CAD)',
                    'symbol' => '$',
                ],
                'GBP' => [
                    'label'  => 'British Pound (GBP)',
                    'symbol' => '£',
                ],
                'EUR' => [
                    'label'  => 'Euro (EUR)',
                    'symbol' => '€',
                ],
                'AUD' => [
                    'label'  => 'Australian Dollar (AUD)',
                    'symbol' => '$',
                ],
                'NZD' => [
                    'label'  => 'New Zealand Dollar (NZD)',
                    'symbol' => '$',
                ],
                'ZAR' => [
                    'label'  => 'South African Rand (ZAR)',
                    'symbol' => 'R',
                ],
            ];
        }

    }
    /**
     * This business's configured currency code (Company Profile > Currency),
     * uppercase, e.g. 'USD'. Falls back to 'USD' for any unrecognized/missing
     * value, same safety-net pattern as paintops_address_format().
     */
    if ( !function_exists( 'paintops_currency_code' ) ) {
        function paintops_currency_code() : string {
            $currencies = paintops_supported_currencies();
            $code = strtoupper( (string) (paintops_company_profile()['currency'] ?? 'USD') );
            return ( array_key_exists( $code, $currencies ) ? $code : 'USD' );
        }

    }
    /**
     * Display symbol for this business's configured currency, e.g. '$' or
     * '£'. Used both for the actual Stripe charge (via paintops_currency_code())
     * and everywhere else in the app that shows a dollar-formatted amount.
     */
    if ( !function_exists( 'paintops_currency_symbol' ) ) {
        function paintops_currency_symbol() : string {
            $currencies = paintops_supported_currencies();
            return $currencies[paintops_currency_code()]['symbol'] ?? '$';
        }

    }
    /**
     * American vs British spelling for the handful of user-facing words
     * that differ between them (Labor/Labour, Favorite/Favourite, etc.),
     * keyed off the Company Profile's Country setting (see
     * paintops_company_profile()), NOT Currency or Measurement Units,
     * since spelling convention tracks the business's own English variant
     * rather than what money or units it happens to use. Defaults to the
     * American form for 'US' or an unset/unrecognized country (matches
     * every hardcoded string already in this codebase before this
     * existed); every other country gets the British form.
     *
     * Deliberately only ever used on VISIBLE text, labels, headings,
     * button copy, tool-card titles, never on internal field/column/JS-
     * variable names (labor_rate_per_day, ld_* calculator fields,
     * 'behavior' in scrollIntoView()'s options object, etc.). Renaming
     * those risks breaking already-saved settings/quotes or, in
     * scrollIntoView's case, is a literal browser API property name that
     * has to stay spelled the American way regardless of anything else on
     * the page.
     */
    function paintops_spelling(  string $american, string $british  ) : string {
        $profile = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
        $country = (string) ($profile['country'] ?? 'US');
        return ( $country === 'US' ? $american : $british );
    }

    /**
     * Allowed "week" ordinals and weekday names for a floating holiday rule
     * (e.g. the "fourth" "Thursday" of a month). Single source for validation
     * and the Settings dropdowns. strtotime() understands these exact words,
     * which is how a rule resolves to a real date in
     * paintops_calendar_holiday_day().
     */
    function paintops_holiday_week_ordinals() : array {
        return [
            'first',
            'second',
            'third',
            'fourth',
            'last'
        ];
    }

    function paintops_holiday_weekday_names() : array {
        return [
            'Sunday',
            'Monday',
            'Tuesday',
            'Wednesday',
            'Thursday',
            'Friday',
            'Saturday'
        ];
    }

    /**
     * The popular holidays seeded into the Calendar Holidays list until an
     * admin first customizes it. All fixed dates; labels go through
     * paintops_spelling() so they track the business's country (Company Profile
     * > Country), these four read the same in US and UK English.
     */
    function paintops_default_calendar_holidays() : array {
        return [
            [
                'type'  => 'fixed',
                'month' => 12,
                'day'   => 24,
                'label' => paintops_spelling( 'Christmas Eve', 'Christmas Eve' ),
            ],
            [
                'type'  => 'fixed',
                'month' => 12,
                'day'   => 25,
                'label' => paintops_spelling( 'Christmas Day', 'Christmas Day' ),
            ],
            [
                'type'  => 'fixed',
                'month' => 12,
                'day'   => 31,
                'label' => paintops_spelling( "New Year's Eve", "New Year's Eve" ),
            ],
            [
                'type'  => 'fixed',
                'month' => 1,
                'day'   => 1,
                'label' => paintops_spelling( "New Year's Day", "New Year's Day" ),
            ]
        ];
    }

    /**
     * One-click US holiday presets offered as buttons in the Settings section.
     * The moving ones use the Nth-weekday floating rule; two are fixed dates.
     * Single source for both the buttons and the handler that adds them. Labor
     * Day runs through paintops_spelling() for the American/British form.
     */
    function paintops_us_holiday_presets() : array {
        return [
            'mlk'          => [
                'type'    => 'float',
                'month'   => 1,
                'week'    => 'third',
                'weekday' => 'Monday',
                'label'   => 'Martin Luther King Jr. Day',
            ],
            'presidents'   => [
                'type'    => 'float',
                'month'   => 2,
                'week'    => 'third',
                'weekday' => 'Monday',
                'label'   => "Presidents' Day",
            ],
            'memorial'     => [
                'type'    => 'float',
                'month'   => 5,
                'week'    => 'last',
                'weekday' => 'Monday',
                'label'   => 'Memorial Day',
            ],
            'juneteenth'   => [
                'type'  => 'fixed',
                'month' => 6,
                'day'   => 19,
                'label' => 'Juneteenth',
            ],
            'independence' => [
                'type'  => 'fixed',
                'month' => 7,
                'day'   => 4,
                'label' => 'Independence Day',
            ],
            'labor'        => [
                'type'    => 'float',
                'month'   => 9,
                'week'    => 'first',
                'weekday' => 'Monday',
                'label'   => paintops_spelling( 'Labor Day', 'Labour Day' ),
            ],
            'columbus'     => [
                'type'    => 'float',
                'month'   => 10,
                'week'    => 'second',
                'weekday' => 'Monday',
                'label'   => 'Columbus Day',
            ],
            'veterans'     => [
                'type'  => 'fixed',
                'month' => 11,
                'day'   => 11,
                'label' => 'Veterans Day',
            ],
            'thanksgiving' => [
                'type'    => 'float',
                'month'   => 11,
                'week'    => 'fourth',
                'weekday' => 'Thursday',
                'label'   => 'Thanksgiving Day',
            ],
        ];
    }

    /**
     * Holiday labels shown on their date in the Calendar (includes/calendar.php)
     *, display-only notes, NOT time-off or scheduling. Managed under PaintOps
     * > Settings > Calendar Holidays; stored in the paintops_calendar_holidays
     * option as a list of rows, each either
     *   fixed:  ['type'=>'fixed','month'=>1-12,'day'=>1-31,'label'=>...]
     *   float:  ['type'=>'float','month'=>1-12,'week'=>ordinal,'weekday'=>name,'label'=>...]
     * keyed by month + day/rule only, so each recurs every year. A row missing
     * 'type' is treated as fixed (back-compat). Until the section is first saved
     * the option is unset and the popular defaults are returned; once saved, the
     * stored list, even an empty one (all removed), is authoritative. Invalid
     * rows are dropped so a malformed option can't emit a broken label.
     */
    function paintops_calendar_holidays() : array {
        $stored = get_option( 'paintops_calendar_holidays', false );
        if ( $stored === false ) {
            return paintops_default_calendar_holidays();
        }
        $out = [];
        foreach ( (array) $stored as $h ) {
            $type = ( ($h['type'] ?? 'fixed') === 'float' ? 'float' : 'fixed' );
            $m = (int) ($h['month'] ?? 0);
            $label = trim( (string) ($h['label'] ?? '') );
            if ( $m < 1 || $m > 12 || $label === '' ) {
                continue;
            }
            if ( $type === 'float' ) {
                $week = (string) ($h['week'] ?? '');
                $weekday = (string) ($h['weekday'] ?? '');
                if ( !in_array( $week, paintops_holiday_week_ordinals(), true ) || !in_array( $weekday, paintops_holiday_weekday_names(), true ) ) {
                    continue;
                }
                $out[] = [
                    'type'    => 'float',
                    'month'   => $m,
                    'week'    => $week,
                    'weekday' => $weekday,
                    'label'   => $label,
                ];
            } else {
                $d = (int) ($h['day'] ?? 0);
                if ( $d < 1 || $d > 31 ) {
                    continue;
                }
                $out[] = [
                    'type'  => 'fixed',
                    'month' => $m,
                    'day'   => $d,
                    'label' => $label,
                ];
            }
        }
        return $out;
    }

    /**
     * Resolve one holiday to its day-of-month in a given year, the real date
     * for a floating rule (e.g. "fourth Thursday of November 2026" -> 26), or a
     * fixed day clamped to that month's actual length (so a "Feb 29" holiday
     * lands on the 28th in a non-leap year). Returns null for an invalid row.
     */
    function paintops_calendar_holiday_day(  array $h, int $year  ) : ?int {
        $month = (int) ($h['month'] ?? 0);
        if ( $month < 1 || $month > 12 ) {
            return null;
        }
        if ( ($h['type'] ?? 'fixed') === 'float' ) {
            $week = (string) ($h['week'] ?? '');
            $weekday = (string) ($h['weekday'] ?? '');
            if ( !in_array( $week, paintops_holiday_week_ordinals(), true ) || !in_array( $weekday, paintops_holiday_weekday_names(), true ) ) {
                return null;
            }
            $monthName = date( 'F', mktime(
                0,
                0,
                0,
                $month,
                1,
                $year
            ) );
            $ts = strtotime( "{$week} {$weekday} of {$monthName} {$year}" );
            // Guard: only "first".."fourth"/"last" are offered (all valid), but
            // confirm the resolved date is still in the intended month.
            if ( $ts === false || (int) date( 'n', $ts ) !== $month ) {
                return null;
            }
            return (int) date( 'j', $ts );
        }
        $d = (int) ($h['day'] ?? 0);
        if ( $d < 1 ) {
            return null;
        }
        $maxDay = (int) date( 't', mktime(
            0,
            0,
            0,
            $month,
            1,
            $year
        ) );
        return min( $d, $maxDay );
    }

    /**
     * [day => label] for holidays that fall in the given month/year, with any
     * floating rules resolved to their real date. Consumed by
     * includes/calendar.php. If two land on the same day, the first defined wins.
     */
    function paintops_calendar_holiday_labels(  int $year, int $month  ) : array {
        $map = [];
        foreach ( paintops_calendar_holidays() as $h ) {
            if ( (int) $h['month'] !== $month ) {
                continue;
            }
            $day = paintops_calendar_holiday_day( $h, $year );
            if ( $day === null ) {
                continue;
            }
            if ( !isset( $map[$day] ) ) {
                $map[$day] = $h['label'];
            }
        }
        return $map;
    }

    /**
     * Human description of when a holiday occurs, for the Settings list,
     * "Dec 25" for a fixed date, "Fourth Thursday of Nov" for a floating rule.
     */
    function paintops_calendar_holiday_when_label(  array $h  ) : string {
        $monthName = date( 'M', mktime(
            0,
            0,
            0,
            (int) ($h['month'] ?? 1),
            1,
            2024
        ) );
        if ( ($h['type'] ?? 'fixed') === 'float' ) {
            return ucfirst( (string) ($h['week'] ?? '') ) . ' ' . (string) ($h['weekday'] ?? '') . ' of ' . $monthName;
        }
        return $monthName . ' ' . (int) ($h['day'] ?? 0);
    }

    /**
     * Sales Tax / VAT, see the 'tax_label'/'tax_percent' fields on
     * PaintOps > Business > Pricing Rules (paintops_default_pricing_rules()).
     * Both default to a no-op (0%, "Sales Tax") so a business that's never
     * touched this setting sees no change at all, no tax line, no
     * behavior difference on quotes, PDFs, or the customer view.
     */
    if ( !function_exists( 'paintops_tax_label' ) ) {
        function paintops_tax_label() : string {
            $rules = paintops_pricing_rules();
            $label = trim( (string) ($rules['tax_label'] ?? '') );
            return ( $label !== '' ? $label : 'Sales Tax' );
        }

    }
    if ( !function_exists( 'paintops_tax_percent' ) ) {
        function paintops_tax_percent() : float {
            $rules = paintops_pricing_rules();
            return max( 0, (float) ($rules['tax_percent'] ?? 0) );
        }

    }
    /**
     * Tax amount (in whole currency units, same convention as every other
     * cost field in this app, see supplyCost1-5/estTotal) for a given
     * pre-tax base. Applied to the MARKED-UP quote total (subtotal x
     * markup, i.e. what the itemize card already labels "Total Suggested"),
     * not the raw line-item subtotal, sales tax/VAT is charged on the
     * actual sale price, not a pre-markup internal cost figure. Rounded to
     * the nearest whole unit to match how every other amount here is
     * stored (intval on save, see estTax on cs_estimates).
     */
    if ( !function_exists( 'paintops_calc_tax_amount' ) ) {
        function paintops_calc_tax_amount(  float $preTaxTotal  ) : int {
            $percent = paintops_tax_percent();
            if ( $percent <= 0 || $preTaxTotal <= 0 ) {
                return 0;
            }
            return (int) round( $preTaxTotal * ($percent / 100) );
        }

    }
    /**
     * Decodes the JSON-encoded overflow itemized-quote rows (slots 6-20)
     * stored in cs_estimates.supplyExtra. Slots 1-5 stay exactly as they
     * always were, their own discrete supplyDesc1-5/supplyCost1-5 columns
     *, this column only holds whatever gets typed into the additional
     * rows that appear as the itemize card is expanded (see the "+ Add
     * Line" button in estimates.php and the calculators' dynamic
     * slot-claiming JS in includes/trade-calculators.php, both of which can
     * now reach past slot 5). Always returns a clean, reindexed list of
     * ['desc' => string, 'cost' => int] pairs in original entry order,
     * silently dropping anything malformed/blank rather than fataling on
     * bad or legacy (pre-this-feature, NULL) data.
     */
    if ( !function_exists( 'paintops_decode_supply_extra_items' ) ) {
        function paintops_decode_supply_extra_items(  $raw  ) : array {
            if ( empty( $raw ) || !is_string( $raw ) ) {
                return [];
            }
            $decoded = json_decode( $raw, true );
            if ( !is_array( $decoded ) ) {
                return [];
            }
            $items = [];
            foreach ( $decoded as $row ) {
                if ( !is_array( $row ) ) {
                    continue;
                }
                $desc = ( isset( $row['desc'] ) ? (string) $row['desc'] : '' );
                $cost = ( isset( $row['cost'] ) ? (int) $row['cost'] : 0 );
                if ( $desc === '' && $cost === 0 ) {
                    continue;
                }
                $items[] = [
                    'desc' => $desc,
                    'cost' => $cost,
                ];
            }
            return $items;
        }

    }
    /**
     * Measurement system options for Company Profile > Measurement Units.
     * Same free/uncoupled-setting pattern as Country and Currency, this
     * only ever changes labels and starter default values in the built-in
     * Paint Calculator, and which Unit option a Price List row defaults
     * toward. It never restricts what a user can actually type into any
     * number field.
     */
    if ( !function_exists( 'paintops_measurement_units_options' ) ) {
        function paintops_measurement_units_options() : array {
            return [
                'imperial' => [
                    'label' => 'Imperial (feet, sq ft)',
                ],
                'metric'   => [
                    'label' => 'Metric (meters, sq meters)',
                ],
            ];
        }

    }
    /**
     * This business's configured measurement system, e.g. 'imperial'. Falls
     * back to 'imperial' for any unrecognized/missing value, same
     * safety-net pattern as paintops_address_format()/paintops_currency_code().
     */
    if ( !function_exists( 'paintops_measurement_unit' ) ) {
        function paintops_measurement_unit() : string {
            $options = paintops_measurement_units_options();
            $units = (string) (paintops_company_profile()['units'] ?? 'imperial');
            return ( array_key_exists( $units, $options ) ? $units : 'imperial' );
        }

    }
    /**
     * Convenience boolean for the common "is this business metric?" check,
     * used by the Paint Calculator and the Price List's Unit dropdown.
     */
    if ( !function_exists( 'paintops_uses_metric' ) ) {
        function paintops_uses_metric() : bool {
            return paintops_measurement_unit() === 'metric';
        }

    }
    /**
     * Maximum number of Job Photos allowed per job. Admin-configurable (see
     * "Job Photo Limit" under Business Settings > Company Profile) rather
     * than a fixed number, since what's reasonable varies a lot by trade,
     * a storm-prep job documenting board-up work might need more than a
     * quick handyman visit. Defaults to 15. A value of 0 or less is treated
     * as "use the default" rather than "no limit", so a blank/cleared field
     * never accidentally removes the cap entirely.
     */
    function paintops_job_photo_limit() : int {
        $limit = (int) get_option( 'paintops_job_photo_limit', 15 );
        return ( $limit > 0 ? $limit : 15 );
    }

    /**
     * Standard mail headers for every email PaintOps sends, customer quote
     * emails, owner notifications, worker job reminders. Without this,
     * wp_mail() falls back to a generic "wordpress@yourdomain.com" From
     * address, which looks unprofessional and is a common cause of messages
     * landing in spam. This does NOT fix underlying deliverability by itself
     * (see paintops_stripe_webhook_url()'s neighbor note in Settings), it
     * just makes sure whatever transport is actually sending mail presents a
     * consistent, real From/Reply-To identity rather than PHP's default.
     */
    function paintops_mail_headers() : array {
        $company = paintops_company_profile();
        $email = ( !empty( $company['email'] ) ? $company['email'] : get_option( 'admin_email' ) );
        $name = ( !empty( $company['name'] ) ? $company['name'] : 'PaintOps' );
        return ['From: ' . $name . ' <' . $email . '>', 'Reply-To: ' . $email];
    }

    /**
     * Best-effort signal that *something* is customizing how WordPress sends
     * mail, almost every SMTP plugin (WP Mail SMTP, Post SMTP, FluentSMTP,
     * Easy WP SMTP, etc.) works by hooking phpmailer_init to configure an
     * authenticated SMTP connection before PHPMailer sends. If nothing has
     * hooked it, WordPress is sending through PHP's built-in mail(), which
     * most receiving providers (Gmail, Outlook, etc.) increasingly spam-filter
     * or reject outright since it has no SPF/DKIM authentication behind it.
     *
     * This can't prove mail is actually being delivered, only a real test
     * send (see paintops_send_test_email()) plus checking the SMTP plugin's
     * own logs can do that. It's a cheap, plugin-agnostic first signal so we
     * can nudge site owners toward fixing this before it becomes a support
     * ticket ("customer never got their quote").
     */
    function paintops_mail_transport_configured() : bool {
        return (bool) has_filter( 'phpmailer_init' );
    }

    /**
     * Send a one-off test email to confirm PaintOps mail is being handed off
     * successfully. Returns true/false from wp_mail(), a true here only
     * means the configured transport accepted it, not that it was truly
     * delivered to the inbox, but it does catch outright failures (e.g. mail()
     * disabled on the host, bad SMTP credentials rejecting at send time).
     */
    function paintops_send_test_email() : bool {
        $company = paintops_company_profile();
        $to = ( !empty( $company['email'] ) ? $company['email'] : get_option( 'admin_email' ) );
        $subject = 'PaintOps test email';
        $message = "This is a test email from PaintOps on " . home_url( '/' ) . ".\n\nIf you received this, outgoing mail is working.";
        return (bool) wp_mail(
            $to,
            $subject,
            $message,
            paintops_mail_headers()
        );
    }

    /**
     * Read job categories, keyed by numeric ID (the same IDs stored in the
     * jobs/estimates tables' jobCat column). Falls back to defaults if the
     * option is missing or empty.
     */
    function paintops_job_categories() : array {
        $saved = get_option( 'paintops_job_categories', null );
        $categories = ( !is_array( $saved ) || empty( $saved ) ? paintops_default_job_categories() : $saved );
        return array_map( 'paintops_normalize_job_category', $categories );
    }

    /**
     * Starter Job Classifications, a second, independent axis from Job
     * Category (which describes the trade/type of work). This describes
     * the nature of how the job came about (a routine repaint vs. new
     * construction vs. a referral channel vs. storm/insurance work), used
     * for the profit-by-classification breakdown on the Profits page.
     *
     * IDs 1-5 match the values every existing job in the field already has
     * saved in its jobClass column (this used to be a hardcoded array in
     * paintops_get_job_class_name()), changing what those IDs mean, or their
     * order, would silently relabel every job ever entered. New entries
     * always get the next free ID instead.
     */
    function paintops_default_job_classifications() : array {
        return [
            1 => 'Repaint',
            2 => 'New Construction',
            3 => 'Realtor',
            4 => 'Specialty or Small Project',
            5 => 'Other',
            6 => 'Property Management/HOA',
            7 => 'Insurance/Storm Restoration',
        ];
    }

    /**
     * Read Job Classifications, keyed by numeric ID (the same IDs stored in
     * the jobs table's jobClass column). Falls back to defaults if the
     * option is missing or empty.
     */
    function paintops_job_classifications() : array {
        $saved = get_option( 'paintops_job_classifications', null );
        return ( !is_array( $saved ) || empty( $saved ) ? paintops_default_job_classifications() : $saved );
    }

    /**
     * Read pricing rules, merging in defaults for any missing keys.
     */
    function paintops_pricing_rules() : array {
        $saved = get_option( 'paintops_pricing_rules', [] );
        if ( !is_array( $saved ) ) {
            $saved = [];
        }
        return array_merge( paintops_default_pricing_rules(), $saved );
    }

    /**
     * Compute the deposit for a quote total, honoring a job's payment_plan.
     *
     * This is the single place deposit math happens. The quote email in
     * estimates.php, the public customer accept page, and the Stripe Checkout
     * amount all call this instead of each re-implementing the threshold
     * percentages from Pricing Rules, so a customer can never be shown one
     * deposit figure and charged another.
     */
    function paintops_calculate_deposit(  float $est_total, string $payment_plan  ) : array {
        $pricing = paintops_pricing_rules();
        if ( $payment_plan === 'no_deposit' || $est_total <= 0 ) {
            return [
                'requires_deposit' => false,
                'percent'          => 0.0,
                'amount'           => 0,
                'remaining'        => max( 0, (int) round( $est_total ) ),
            ];
        }
        if ( $est_total > $pricing['deposit_high_threshold'] ) {
            $percent = (float) $pricing['deposit_high_percent'];
        } elseif ( $est_total > $pricing['deposit_mid_threshold'] ) {
            $percent = (float) $pricing['deposit_mid_percent'];
        } else {
            $percent = 0.0;
        }
        $amount = ( $percent > 0 ? (int) round( $est_total * ($percent / 100) ) : 0 );
        return [
            'requires_deposit' => $amount > 0,
            'percent'          => $percent,
            'amount'           => $amount,
            'remaining'        => (int) round( $est_total ) - $amount,
        ];
    }

    // Stripe, OpenAI, and vehicle helpers moved to includes/pro-services.php in the premium build only.
    /**
     * Who may use the Vehicle Log: any staff-level user, or a Painter/Helper,
     * each logs their OWN trips/expenses (workers see only their own; staff see
     * everyone's). Broader than most features on purpose, since employees who
     * drive their own vehicles for work need to record their own mileage.
     */
    function paintops_user_can_use_vehicle_log() : bool {
        if ( !is_user_logged_in() ) {
            return false;
        }
        if ( paintops_user_can_manage() ) {
            return true;
        }
        $user = wp_get_current_user();
        return $user && in_array( paintops_worker_role_slug(), (array) $user->roles, true );
    }

    /*
     * PREMIUM-ONLY BLOCK (2026-07-21): receipt storage, the printable
     * vehicle and business expense reports, and their route hooks, all
     * stripped from the free build (wp.org guideline 5). The add_action
     * registrations live INSIDE the block with their handlers so the free
     * build registers nothing. Do not replace the literal call.
     */
    // Vehicle and business expense reports (Pro) moved to
    // includes/pro-expense-reports.php (2026-07-24): the premium build's
    // main-file processor cannot re-emit inline HTML inside a premium-only
    // block, include files are copied verbatim instead. See that file.
    /* ================================================================== *
     *  Backup & Data export (Business page)
     *
     *  Owner/admin download of the PaintOps RECORDS, a ZIP with one CSV
     *  per PaintOps table plus a settings.json and a README. This is an
     *  export for the accountant / a spare off-site copy / moving sites,
     *  NOT a full backup: photo & receipt IMAGE FILES live in the WordPress
     *  media library and are not included (the README and the Business-page
     *  card say so, and point to a real full-site backup guide). No import/
     *  restore is offered yet. Secrets (API keys, tokens) are never exported.
     * ================================================================== */
    /**
     * Where the "how do I do a complete backup" link points. A page you
     * control so the advice can change without a code release; overridable
     * by the paintops_backup_help_url option or filter.
     */
    function paintops_backup_help_url() : string {
        $default = 'https://paintoperations.com/paintops-backups/';
        $url = (string) get_option( 'paintops_backup_help_url', '' );
        if ( $url === '' ) {
            $url = $default;
        }
        return (string) apply_filters( 'paintops_backup_help_url', $url );
    }

    /**
     * True if an option name looks like it holds a secret we must never put
     * into an export (API keys, tokens, client secrets, webhook signing keys).
     */
    function paintops_backup_is_secret_option(  string $name  ) : bool {
        $name_l = strtolower( $name );
        foreach ( [
            'api_key',
            'apikey',
            'secret',
            'token',
            'password',
            'signing',
            'private_key'
        ] as $needle ) {
            if ( strpos( $name_l, $needle ) !== false ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Build a CSV string for one full table: a header row of column names
     * followed by every data row. Empty table still yields its header row so
     * the schema is visible. $table is a real table name from our own DB.
     */
    function paintops_backup_table_csv(  string $table  ) : string {
        global $wpdb;
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- in-memory php://temp stream for fputcsv; WP_Filesystem has no streaming CSV writer.
        $fh = fopen( 'php://temp', 'r+' );
        $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i", $table ), ARRAY_A );
        if ( !empty( $rows ) ) {
            fputcsv( $fh, array_keys( $rows[0] ) );
            foreach ( $rows as $row ) {
                $out = [];
                foreach ( $row as $val ) {
                    $out[] = ( $val === null ? '' : $val );
                }
                fputcsv( $fh, $out );
            }
        } else {
            $cols = $wpdb->get_col( $wpdb->prepare( "DESC %i", $table ), 0 );
            if ( !empty( $cols ) ) {
                fputcsv( $fh, $cols );
            }
        }
        rewind( $fh );
        $csv = stream_get_contents( $fh );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the php://temp stream above.
        fclose( $fh );
        return (string) $csv;
    }

    /**
     * Collect every PaintOps table (discovered dynamically as the site's
     * cs_* tables, so new tables are captured automatically) as
     * [friendly_name => ['table' => full, 'rows' => count]] and the settings.
     */
    function paintops_backup_collect() : array {
        global $wpdb;
        $prefix = $wpdb->prefix . 'cs_';
        $like = $wpdb->esc_like( $prefix ) . '%';
        $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
        $tables = ( is_array( $tables ) ? $tables : [] );
        $out = [];
        foreach ( $tables as $full ) {
            $name = $full;
            if ( strpos( $full, $wpdb->prefix ) === 0 ) {
                $name = substr( $full, strlen( $wpdb->prefix ) );
                // e.g. cs_jobs
            }
            $out[$name] = [
                'table' => $full,
                'rows'  => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i", $full ) ),
            ];
        }
        // Settings: every paintops_* option except anything that looks secret
        // and the noisy per-month AI usage counters/transients.
        $settings = [];
        $opt_rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM %i WHERE option_name LIKE %s", $wpdb->options, $wpdb->esc_like( 'paintops_' ) . '%' ), ARRAY_A );
        if ( is_array( $opt_rows ) ) {
            foreach ( $opt_rows as $o ) {
                $oname = (string) $o['option_name'];
                if ( paintops_backup_is_secret_option( $oname ) ) {
                    continue;
                }
                if ( strpos( $oname, '_transient' ) !== false ) {
                    continue;
                }
                $settings[$oname] = maybe_unserialize( $o['option_value'] );
            }
        }
        return [
            'tables'   => $out,
            'settings' => $settings,
        ];
    }

    /**
     * README shipped inside the export so whoever opens it later understands
     * exactly what it is, and, crucially, what it is NOT.
     */
    function paintops_backup_readme(  array $manifest  ) : string {
        $when = (string) ($manifest['exported_at'] ?? '');
        $site = (string) ($manifest['site_url'] ?? '');
        $ver = (string) ($manifest['plugin_version'] ?? '');
        $help = paintops_backup_help_url();
        return "PaintOps Data Export\n" . "====================\n\n" . "Exported: {$when}\n" . "Site: {$site}\n" . "PaintOps version: {$ver}\n\n" . "WHAT THIS IS\n" . "-----------\n" . "A copy of your PaintOps RECORDS: one CSV file per data table in the\n" . "/data folder (jobs, customers, payments, expenses, worker hours,\n" . "vehicle log, invoices, and so on), plus settings.json with your\n" . "PaintOps settings. Great for handing your books to an accountant,\n" . "keeping a spare off-site copy, or moving to a new site.\n\n" . "WHAT THIS IS NOT (please read)\n" . "------------------------------\n" . "This is NOT a complete backup. Your job PHOTOS and RECEIPT IMAGES are\n" . "stored in the WordPress Media Library as image files, and image files\n" . "are NOT included here, only the records that reference them.\n\n" . "For a full backup that includes your photos AND can be restored, use a\n" . "WordPress site-backup plugin (database + files). See:\n" . "  {$help}\n\n" . "Secrets (API keys, tokens, Stripe keys) are deliberately left out of\n" . "this export for safety.\n\n" . "There is no one-click re-import yet; these CSVs are for reference,\n" . "your accountant, or a developer restoring data manually.\n";
    }

    /**
     * Stream a single JSON export, the fallback when the server has no
     * ZipArchive so the button always produces something usable.
     */
    function paintops_backup_stream_json(  array $collected, array $manifest  ) : void {
        global $wpdb;
        $payload = [
            'manifest' => $manifest,
            'settings' => $collected['settings'],
            'tables'   => [],
        ];
        foreach ( $collected['tables'] as $name => $info ) {
            $payload['tables'][$name] = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i", $info['table'] ), ARRAY_A );
        }
        $filename = 'paintops-data-' . current_time( 'Y-m-d' ) . '.json';
        nocache_headers();
        header( 'Content-Type: application/json; charset=utf-8' );
        header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
        echo wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
        exit;
    }

    /**
     * Intercept ?paintops_data_export=1 and stream the download. Owner/admin
     * only, nonce-protected. Runs early (before the theme) like the report
     * pages, and exits after sending the file.
     */
    function paintops_maybe_export_data() : void {
        if ( empty( $_GET['paintops_data_export'] ) || !function_exists( 'wp_get_current_user' ) ) {
            return;
        }
        $can = current_user_can( 'manage_options' ) || function_exists( 'paintops_user_can_view_financials' ) && paintops_user_can_view_financials();
        if ( !$can ) {
            wp_die( 'You do not have permission to export PaintOps data.' );
        }
        $nonce = ( isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '' );
        if ( !wp_verify_nonce( $nonce, 'paintops_data_export' ) ) {
            wp_die( 'This download link has expired. Please go back to the Business page and start the download again.' );
        }
        $collected = paintops_backup_collect();
        $manifest = [
            'exported_at'      => current_time( 'mysql' ),
            'site_url'         => home_url(),
            'plugin_version'   => ( defined( 'PAINTOPS_VERSION' ) ? PAINTOPS_VERSION : '' ),
            'table_row_counts' => array_map( function ( $t ) {
                return (int) $t['rows'];
            }, $collected['tables'] ),
            'note'             => 'Records export only. Photo/receipt image files are not included, see README.txt.',
        ];
        // No ZipArchive on this host? Fall back to a single JSON so the
        // feature still works everywhere.
        if ( !class_exists( 'ZipArchive' ) ) {
            paintops_backup_stream_json( $collected, $manifest );
            return;
            // (stream exits)
        }
        global $wpdb;
        $tmp = ( function_exists( 'wp_tempnam' ) ? wp_tempnam( 'paintops-export.zip' ) : tempnam( sys_get_temp_dir(), 'po_exp' ) );
        if ( !$tmp ) {
            wp_die( 'Could not prepare the export file. Please try again.' );
        }
        $zip = new ZipArchive();
        if ( $zip->open( $tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
            wp_delete_file( $tmp );
            wp_die( 'Could not create the export archive. Please try again.' );
        }
        foreach ( $collected['tables'] as $name => $info ) {
            $zip->addFromString( 'data/' . $name . '.csv', paintops_backup_table_csv( $info['table'] ) );
        }
        $zip->addFromString( 'settings.json', (string) wp_json_encode( $collected['settings'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
        $zip->addFromString( 'manifest.json', (string) wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
        $zip->addFromString( 'README.txt', paintops_backup_readme( $manifest ) );
        $zip->close();
        $filename = 'paintops-data-' . current_time( 'Y-m-d' ) . '.zip';
        nocache_headers();
        header( 'Content-Type: application/zip' );
        header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
        header( 'Content-Length: ' . filesize( $tmp ) );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams the just-built export ZIP to the browser; the temp file is deleted on the next line.
        readfile( $tmp );
        wp_delete_file( $tmp );
        exit;
    }

    add_action( 'wp_loaded', 'paintops_maybe_export_data', 1 );
    add_action( 'admin_init', 'paintops_maybe_export_data', 1 );
    add_action( 'template_redirect', 'paintops_maybe_export_data', 1 );
    /**
     * Create or update the PaintOps database tables.
     */
    function paintops_install() : void {
        global $wpdb;
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        $charset = $wpdb->get_charset_collate();
        $calendar = paintops_table( 'calendar' );
        $estimates = paintops_table( 'estimates' );
        $jobs = paintops_table( 'jobs' );
        $expenses = paintops_table( 'job_expenses' );
        $financials = paintops_table( 'job_financials' );
        $hours = paintops_table( 'job_worker_hours' );
        $payments = paintops_table( 'job_payments' );
        $timeoff = paintops_table( 'timeoff' );
        $customers = paintops_table( 'customers' );
        $paint_colors = paintops_table( 'job_paint_colors' );
        $photos = paintops_table( 'job_photos' );
        $checkins = paintops_table( 'job_checkins' );
        $vehicle_log = paintops_table( 'vehicle_log' );
        $job_invoices = paintops_table( 'job_invoices' );
        $comm_log = paintops_table( 'comm_log' );
        $business_expenses = paintops_table( 'business_expenses' );
        dbDelta( "CREATE TABLE {$calendar} (\n        id INT(11) NOT NULL AUTO_INCREMENT,\n        description TEXT NOT NULL,\n        name TEXT NOT NULL,\n        year INT(11) NOT NULL,\n        month INT(11) NOT NULL,\n        day INT(11) NOT NULL,\n        PRIMARY KEY (id)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$estimates} (\n        estID INT(11) NOT NULL AUTO_INCREMENT,\n        jobID INT(11) NOT NULL DEFAULT 0,\n        createdOn DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        createdBy INT(11) NOT NULL DEFAULT 0,\n        quoteTitle TEXT NOT NULL,\n        supplyDesc1 TEXT NOT NULL,\n        supplyDesc2 TEXT NOT NULL,\n        supplyDesc3 TEXT NOT NULL,\n        supplyDesc4 TEXT NOT NULL,\n        supplyDesc5 TEXT NOT NULL,\n        supplyCost1 INT(11) NOT NULL DEFAULT 0,\n        supplyCost2 INT(11) NOT NULL DEFAULT 0,\n        supplyCost3 INT(11) NOT NULL DEFAULT 0,\n        supplyCost4 INT(11) NOT NULL DEFAULT 0,\n        supplyCost5 INT(11) NOT NULL DEFAULT 0,\n        supplyExtra LONGTEXT NULL,\n        estDetails LONGTEXT NOT NULL,\n        estTotal INT(11) NOT NULL DEFAULT 0,\n        estTax INT(11) NOT NULL DEFAULT 0,\n        accepted TINYINT(4) NOT NULL DEFAULT 0,\n        isBid TINYINT(1) NOT NULL DEFAULT 0,\n        show_itemized TINYINT(1) NOT NULL DEFAULT 0,\n        status VARCHAR(30) NOT NULL DEFAULT 'draft',\n        accepted_date DATE NULL,\n        declined_date DATE NULL,\n        converted_to_job TINYINT(1) NOT NULL DEFAULT 0,\n        public_token VARCHAR(64) NULL,\n        deposit_paid TINYINT(1) NOT NULL DEFAULT 0,\n        deposit_paid_at DATETIME NULL,\n        stripe_checkout_session_id VARCHAR(120) NULL,\n        company_id BIGINT UNSIGNED NOT NULL DEFAULT 1,\n        PRIMARY KEY (estID),\n        KEY jobID (jobID),\n        KEY status (status),\n        KEY company_id (company_id),\n        KEY public_token (public_token)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$jobs} (\n        id INT(11) NOT NULL AUTO_INCREMENT,\n        custId INT(11) NOT NULL DEFAULT 0,\n        custFirst TEXT NOT NULL,\n        custLast TEXT NOT NULL,\n        custServiceAddress TEXT NOT NULL,\n        custServiceCity TEXT NOT NULL,\n        custServiceZip VARCHAR(10) NOT NULL DEFAULT '',\n        custPhone TEXT NOT NULL,\n        custEmail TEXT NOT NULL,\n        reqWorkers TEXT NOT NULL,\n        reqDays INT(11) NOT NULL DEFAULT 0,\n        jobCat TINYINT(4) NOT NULL DEFAULT 0,\n        jobTitle TEXT NOT NULL,\n        jobDesc LONGTEXT NOT NULL,\n        estimateID INT(11) NOT NULL DEFAULT 0,\n        invoiceID INT(11) NOT NULL DEFAULT 0,\n        startTime TEXT NOT NULL,\n        startDay TINYINT(4) NOT NULL DEFAULT 0,\n        startMonth TINYINT(4) NOT NULL DEFAULT 0,\n        startYear SMALLINT(4) NOT NULL DEFAULT 0,\n        completed TINYINT(4) NOT NULL DEFAULT 0,\n        addedBy INT(11) NOT NULL DEFAULT 0,\n        created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        updated_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n        notifsSent TINYINT(1) NOT NULL DEFAULT 0,\n        jobType TINYINT(4) NOT NULL DEFAULT 0,\n        isBid TINYINT(1) NOT NULL DEFAULT 0,\n        quote_label VARCHAR(20) NOT NULL DEFAULT 'quote',\n        jobClass TINYINT(1) NOT NULL DEFAULT 5,\n        record_stage VARCHAR(30) NOT NULL DEFAULT 'estimate',\n        quote_type VARCHAR(30) NOT NULL DEFAULT 'estimate',\n        job_status VARCHAR(30) NOT NULL DEFAULT 'not_awarded',\n        awarded_date DATE NULL,\n        completed_date DATE NULL,\n        payment_plan VARCHAR(20) NOT NULL DEFAULT 'deposit_online',\n        company_id BIGINT UNSIGNED NOT NULL DEFAULT 1,\n        parent_job_id BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        visit_type VARCHAR(20) NOT NULL DEFAULT 'original',\n        is_billable TINYINT(1) NOT NULL DEFAULT 1,\n        not_won TINYINT(1) NOT NULL DEFAULT 0,\n        no_materials_needed TINYINT(1) NOT NULL DEFAULT 0,\n        review_dismissed TINYINT(1) NOT NULL DEFAULT 0,\n        PRIMARY KEY (id),\n        KEY estimateID (estimateID),\n        KEY job_status (job_status),\n        KEY record_stage (record_stage),\n        KEY company_id (company_id),\n        KEY parent_job_id (parent_job_id)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$financials} (\n        job_id BIGINT UNSIGNED NOT NULL,\n        gross_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        other_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        notes TEXT NULL,\n        updated_by BIGINT UNSIGNED NULL,\n        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n        PRIMARY KEY (job_id)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$expenses} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        expense_date DATE NULL,\n        vendor VARCHAR(150) NOT NULL DEFAULT '',\n        category VARCHAR(80) NOT NULL DEFAULT 'Materials',\n        description TEXT NULL,\n        amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        created_by BIGINT UNSIGNED NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY expense_date (expense_date)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$hours} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        user_id BIGINT UNSIGNED NOT NULL,\n        work_date DATE NOT NULL,\n        hours DECIMAL(8,2) NOT NULL DEFAULT 0.00,\n        hourly_rate DECIMAL(10,2) NOT NULL DEFAULT 0.00,\n        notes TEXT NULL,\n        created_by BIGINT UNSIGNED NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY user_id (user_id),\n        KEY work_date (work_date)\n    ) {$charset};" );
        // Payments log for Job Financials' Gross / Payout section (see
        // paintops_render_job_financial_box() in job-financials-addon.php), one
        // row per payment (a deposit, a milestone, cash/check, the final
        // payment, etc.) rather than a single editable total, so a job with
        // several milestone payments keeps a full record of when and how each
        // one came in. stripe_checkout_session_id is set only for rows Stripe
        // logged automatically (see paintops_mark_deposit_paid() in
        // customer-payments.php), letting a webhook retry recognize a payment
        // it already logged instead of double-counting it.
        dbDelta( "CREATE TABLE {$payments} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        payment_date DATE NOT NULL,\n        amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        method VARCHAR(20) NOT NULL DEFAULT 'Cash',\n        stripe_checkout_session_id VARCHAR(120) NULL,\n        created_by BIGINT UNSIGNED NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY payment_date (payment_date)\n    ) {$charset};" );
        dbDelta( "CREATE TABLE {$timeoff} (\n        id INT(11) NOT NULL AUTO_INCREMENT,\n        user_id TEXT NOT NULL,\n        day INT(11) NOT NULL,\n        month INT(11) NOT NULL,\n        year INT(11) NOT NULL,\n        daysOff INT(11) NOT NULL DEFAULT 1,\n        isRepeat TINYINT(4) NOT NULL DEFAULT 0,\n        PRIMARY KEY (id)\n    ) {$charset};" );
        // Customers are stored here as data records only, never as WP user
        // accounts. Earlier code (create_customer_user_if_needed(), now
        // removed) silently created a real WordPress account with login
        // credentials for anyone whose email showed up on a job, with no
        // notice or consent. This table replaces that: matching by email on
        // a new job just links or creates a plain record here, the same as
        // adding someone to a paper customer list.
        dbDelta( "CREATE TABLE {$customers} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        first_name VARCHAR(100) NOT NULL DEFAULT '',\n        last_name VARCHAR(100) NOT NULL DEFAULT '',\n        email VARCHAR(190) NOT NULL DEFAULT '',\n        phone VARCHAR(30) NOT NULL DEFAULT '',\n        service_address TEXT NULL,\n        service_city VARCHAR(100) NOT NULL DEFAULT '',\n        service_zip VARCHAR(10) NOT NULL DEFAULT '',\n        notes TEXT NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n        company_id BIGINT UNSIGNED NOT NULL DEFAULT 1,\n        PRIMARY KEY (id),\n        KEY email (email),\n        KEY company_id (company_id)\n    ) {$charset};" );
        // Paint colors needed for a job, name/code, where it goes (e.g.
        // "Trim and doors" or "Baby's room"), how much, and sheen/finish.
        // Its own table (rather than cramming this into jobs.jobDesc) so it
        // can be edited on its own small form, the same "own form, own save
        // button" pattern used for Job Financials.
        dbDelta( "CREATE TABLE {$paint_colors} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        color_name VARCHAR(150) NOT NULL DEFAULT '',\n        product VARCHAR(150) NOT NULL DEFAULT '',\n        area VARCHAR(150) NOT NULL DEFAULT '',\n        quantity VARCHAR(60) NOT NULL DEFAULT '',\n        sheen VARCHAR(60) NOT NULL DEFAULT '',\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n        company_id BIGINT UNSIGNED NOT NULL DEFAULT 1,\n        PRIMARY KEY (id),\n        KEY job_id (job_id)\n    ) {$charset};" );
        // Job photos and expense receipts, both are just images attached to
        // a job, stored as real WordPress media attachments (so they show up
        // in the Media Library, survive backups, and work with any offload-
        // to-S3 plugin) with this table only tracking which job/expense each
        // one belongs to and which of the two buckets it's in. "context"
        // separates general job photos (before/after, progress shots, an
        // operational feature, not gated behind Pro, same reasoning as Paint
        // Colors above) from expense receipts (tied to a specific row in
        // cs_job_expenses, which is itself a Pro-only feature, see
        // job-financials-addon.php, so a receipt can only ever exist
        // alongside an expense a Pro site already created).
        dbDelta( "CREATE TABLE {$photos} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        expense_id BIGINT UNSIGNED NULL,\n        context VARCHAR(20) NOT NULL DEFAULT 'photo',\n        attachment_id BIGINT UNSIGNED NULL,\n        uploaded_by BIGINT UNSIGNED NULL,\n        uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        notes TEXT NULL,\n        taken_at DATETIME NULL,\n        gps_lat DECIMAL(10,7) NULL,\n        gps_lng DECIMAL(10,7) NULL,\n        photo_role VARCHAR(10) NULL,\n        paired_photo_id BIGINT UNSIGNED NULL,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY expense_id (expense_id),\n        KEY context (context)\n    ) {$charset};" );
        // Check-in/check-out sessions (see includes/job-checkin.php), the
        // punch-clock record with exact arrival/departure times. Deliberately
        // separate from cs_job_worker_hours: on checkout the computed hours
        // are INSERTed there (that table is what all the profit math reads),
        // while this table keeps the raw in/out audit trail. hours here is
        // the LOGGED (possibly capped) figure matching the hours row;
        // was_capped marks sessions that ran past the forgotten-checkout cap.
        dbDelta( "CREATE TABLE {$checkins} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        user_id BIGINT UNSIGNED NOT NULL,\n        checkin_at DATETIME NOT NULL,\n        checkout_at DATETIME NULL,\n        hours DECIMAL(8,2) NULL,\n        was_capped TINYINT(1) NOT NULL DEFAULT 0,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY user_id (user_id),\n        KEY checkout_at (checkout_at)\n    ) {$charset};" );
        // Vehicle Log (Pro), one row per mileage trip OR vehicle expense, per
        // driver. entry_type mirrors the business's chosen deduction method at
        // the time of entry; miles/from/to are used for 'mileage', amount_cents/
        // category for 'expense'. See includes/vehicle-log.php.
        dbDelta( "CREATE TABLE {$vehicle_log} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        user_id BIGINT UNSIGNED NOT NULL,\n        entry_date DATE NOT NULL,\n        entry_type VARCHAR(10) NOT NULL DEFAULT 'mileage',\n        miles DECIMAL(9,1) NULL,\n        amount_cents BIGINT NULL,\n        category VARCHAR(24) NULL,\n        from_text VARCHAR(191) NULL,\n        to_text VARCHAR(191) NULL,\n        job_id BIGINT UNSIGNED NULL,\n        recurring_id BIGINT UNSIGNED NULL,\n        vehicle_id BIGINT UNSIGNED NULL,\n        receipt_attachment_id BIGINT UNSIGNED NULL,\n        notes TEXT NULL,\n        created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        PRIMARY KEY (id),\n        KEY user_id (user_id),\n        KEY entry_date (entry_date),\n        KEY entry_type (entry_type),\n        KEY recurring_id (recurring_id),\n        KEY vehicle_id (vehicle_id)\n    ) {$charset};" );
        // Invoices (Pro), a stored, numbered invoice for a job's remaining
        // balance (see includes/job-invoices.php). amount_cents is the balance due
        // snapshot at issue time; photo_ids/note_ids are the cs_job_photos rows the
        // owner chose to include; public_token backs the no-login customer pay page
        // (pass 2); status is draft/sent/paid.
        dbDelta( "CREATE TABLE {$job_invoices} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        invoice_number VARCHAR(191) NOT NULL,\n        customer_name VARCHAR(191) NULL,\n        amount_cents BIGINT NOT NULL DEFAULT 0,\n        status VARCHAR(20) NOT NULL DEFAULT 'draft',\n        public_token VARCHAR(64) NULL,\n        photo_ids TEXT NULL,\n        note_ids TEXT NULL,\n        message TEXT NULL,\n        created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        sent_at DATETIME NULL,\n        paid_at DATETIME NULL,\n        stripe_checkout_session_id VARCHAR(191) NULL,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY public_token (public_token),\n        KEY status (status)\n    ) {$charset};" );
        // Communications log (FREE feature, Mike's request 2026-07-21): one
        // append-only row per outbound customer email (quote sent, follow-up
        // sent, invoice sent) so the job page can answer "did we already
        // follow up, and when?". Written by paintops_log_communication();
        // read by paintops_get_job_communications() (staff-only box on the
        // job view). Deliberately plain, no statuses, no editing.
        dbDelta( "CREATE TABLE {$comm_log} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        estimate_id BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        invoice_id BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        comm_type VARCHAR(40) NOT NULL DEFAULT '',\n        recipient VARCHAR(191) NOT NULL DEFAULT '',\n        subject VARCHAR(255) NOT NULL DEFAULT '',\n        sent_by BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        sent_at DATETIME NOT NULL,\n        PRIMARY KEY  (id),\n        KEY job_id (job_id)\n    ) {$charset};" );
        // Business / overhead expenses (Pro), general operating costs that
        // aren't a job cost or a vehicle cost (marketing, supplies, software,
        // meals, events) plus capital assets (is_asset=1) for the accountant's
        // depreciation. Not job-costed; owner/admin only. Receipt image is a WP
        // attachment id (same pattern as the vehicle log).
        dbDelta( "CREATE TABLE {$business_expenses} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        expense_date DATE NULL,\n        category VARCHAR(80) NOT NULL DEFAULT 'Other',\n        vendor VARCHAR(191) NOT NULL DEFAULT '',\n        amount_cents BIGINT NOT NULL DEFAULT 0,\n        is_asset TINYINT(1) NOT NULL DEFAULT 0,\n        job_id BIGINT UNSIGNED NULL,\n        notes TEXT NULL,\n        receipt_attachment_id BIGINT UNSIGNED NULL,\n        created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,\n        created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY expense_date (expense_date),\n        KEY is_asset (is_asset)\n    ) {$charset};" );
        // Crews (2026-07-19, v1.0.105, "lightweight crews", Mike's pick):
        // a crew is a named, colored GROUP of workers used to bulk-fill the
        // job form's worker checkboxes and to filter/report. member_ids uses
        // the same '#id#' convention as jobs.reqWorkers (parse with
        // paintops_parse_worker_ids), one convention everywhere. Workers on a
        // job remain the source of truth (jobs.reqWorkers, unchanged); the
        // job's crew_id column (see $job_columns) only records WHICH crew was
        // used, for the calendar filter and the per-crew profit rollup.
        $crews = paintops_table( 'crews' );
        dbDelta( "CREATE TABLE {$crews} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        name VARCHAR(100) NOT NULL DEFAULT '',\n        color VARCHAR(7) NOT NULL DEFAULT '#4682B4',\n        member_ids TEXT NULL,\n        active TINYINT(1) NOT NULL DEFAULT 1,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY active (active)\n    ) {$charset};" );
        paintops_migrate_missing_columns();
        paintops_migrate_resource_post_type();
        paintops_migrate_category_quote_notes_to_situational();
        paintops_dedupe_situational_clauses();
        paintops_migrate_default_category_scope_components();
        paintops_migrate_job_photos_nullable_attachment();
        paintops_seed_default_options();
        paintops_get_app_page_id();
        paintops_schedule_notifications_cron();
        if ( function_exists( 'paintops_register_worker_role' ) ) {
            paintops_register_worker_role();
        }
        if ( function_exists( 'paintops_seed_default_resources' ) ) {
            paintops_seed_default_resources();
        }
        update_option( 'paintops_db_version', PAINTOPS_VERSION );
    }

    /**
     * Seed default option values on install/upgrade.
     *
     * The Maps API key used to be hardcoded directly in includes/estimates.php
     * and includes/manage-jobs.php, and lives in the paintops_maps_api_key
     * option now instead. This function intentionally does NOT seed a
     * default key, doing so would mean every new install (or every other
     * business this plugin is ever distributed to) silently shares one
     * developer's API key, which can be scraped out of the plugin source and
     * used to run up that key's quota/bill on unrelated sites. Each site is
     * expected to add its own key under PaintOps > Settings; the "Not set"
     * label there makes it obvious when one is missing.
     */
    function paintops_seed_default_options() : void {
        // Seed company profile, job categories, and pricing rules once, from
        // the values that used to be hardcoded, so an upgrade doesn't change
        // anything about how the site already looks and behaves. After this,
        // they're editable under PaintOps > Business.
        if ( get_option( 'paintops_company_profile', null ) === null ) {
            update_option( 'paintops_company_profile', paintops_default_company_profile() );
        }
        if ( get_option( 'paintops_job_categories', null ) === null ) {
            update_option( 'paintops_job_categories', paintops_default_job_categories() );
        }
        if ( get_option( 'paintops_pricing_rules', null ) === null ) {
            update_option( 'paintops_pricing_rules', paintops_default_pricing_rules() );
        }
        // App-page chrome (the theme's own header/footer around the app page).
        // Brand-new installs default to "mobile" (header/footer hidden on phones)
        // so field crews get the whole screen; existing sites updating into this
        // version stay "off" (unchanged) so a live layout never shifts without the
        // owner opting in. A true first install is the only time paintops_db_version
        // is still unset here (paintops_install sets it at its very end), so that
        // absence is what distinguishes a new install from an upgrade.
        if ( get_option( 'paintops_app_chrome_mode', null ) === null ) {
            $paintops_is_new_install = get_option( 'paintops_db_version', false ) === false;
            update_option( 'paintops_app_chrome_mode', ( $paintops_is_new_install ? 'mobile' : 'off' ) );
        }
        if ( get_option( 'paintops_app_chrome_desktop', null ) === null ) {
            update_option( 'paintops_app_chrome_desktop', 'show' );
        }
        // Ensure a notification trigger token exists.
        paintops_notif_token();
    }

    /**
     * Schedule the daily job-notification cron event if it isn't already
     * scheduled. This replaces relying solely on an open, unauthenticated
     * URL (?sendNotif=1) as the only way notifications ever get sent.
     */
    function paintops_schedule_notifications_cron() : void {
        if ( !wp_next_scheduled( 'paintops_daily_notifications' ) ) {
            wp_schedule_event( time(), 'daily', 'paintops_daily_notifications' );
        }
    }

    /**
     * Clear the scheduled cron event on deactivation.
     */
    function paintops_deactivate() : void {
        $timestamp = wp_next_scheduled( 'paintops_daily_notifications' );
        if ( $timestamp ) {
            wp_unschedule_event( $timestamp, 'paintops_daily_notifications' );
        }
    }

    /**
     * Add missing columns for installs created before this consolidated plugin.
     */
    /**
     * Make cs_job_photos.attachment_id NULLable so the Job Notes feed can hold a
     * text-only note (a row with no image). Original schema had it NOT NULL, since
     * every row was a photo/receipt. Idempotent: only ALTERs when the column is
     * still NOT NULL, so it's a no-op on fresh installs (already NULL) and on
     * upgrades after the first run.
     */
    function paintops_migrate_job_photos_nullable_attachment() : void {
        global $wpdb;
        $table = paintops_table( 'job_photos' );
        $col = $wpdb->get_row( $wpdb->prepare( "SELECT IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'attachment_id'", DB_NAME, $table ) );
        if ( $col && strtoupper( (string) $col->IS_NULLABLE ) === 'NO' ) {
            $wpdb->query( $wpdb->prepare( "ALTER TABLE %i MODIFY attachment_id BIGINT UNSIGNED NULL", $table ) );
        }
    }

    // One-time version-gated rename of the legacy rl_resource CPT and its meta key to paintops-prefixed names; no-op when no old rows exist.
    function paintops_migrate_resource_post_type() : void {
        global $wpdb;
        $wpdb->update(
            $wpdb->posts,
            [
                'post_type' => 'paintops_resource',
            ],
            [
                'post_type' => 'rl_resource',
            ],
            ['%s'],
            ['%s']
        );
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time key rename during the version-gated upgrade
        $wpdb->update(
            $wpdb->postmeta,
            [
                'meta_key' => '_paintops_resource_url',
            ],
            [
                'meta_key' => '_rl_url',
            ],
            ['%s'],
            ['%s']
        );
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
    }

    function paintops_migrate_missing_columns() : void {
        global $wpdb;
        $column_exists = static function ( string $table, string $column ) use($wpdb) : bool {
            $found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM %i LIKE %s", $table, $column ) );
            return !empty( $found );
        };
        $jobs = paintops_table( 'jobs' );
        $job_columns = [
            'isBid'                => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN isBid TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'quote_label'          => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN quote_label VARCHAR(20) NOT NULL DEFAULT 'quote'", $jobs ),
            'record_stage'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN record_stage VARCHAR(30) NOT NULL DEFAULT 'estimate'", $jobs ),
            'quote_type'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN quote_type VARCHAR(30) NOT NULL DEFAULT 'estimate'", $jobs ),
            'job_status'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN job_status VARCHAR(30) NOT NULL DEFAULT 'not_awarded'", $jobs ),
            'awarded_date'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN awarded_date DATE NULL", $jobs ),
            'completed_date'       => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN completed_date DATE NULL", $jobs ),
            'payment_plan'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN payment_plan VARCHAR(20) NOT NULL DEFAULT 'deposit_online'", $jobs ),
            'company_id'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN company_id BIGINT UNSIGNED NOT NULL DEFAULT 1", $jobs ),
            'customer_id'          => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN customer_id BIGINT UNSIGNED NOT NULL DEFAULT 0", $jobs ),
            'parent_job_id'        => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN parent_job_id BIGINT UNSIGNED NOT NULL DEFAULT 0", $jobs ),
            'visit_type'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN visit_type VARCHAR(20) NOT NULL DEFAULT 'original'", $jobs ),
            'is_billable'          => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN is_billable TINYINT(1) NOT NULL DEFAULT 1", $jobs ),
            'scope_notes'          => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN scope_notes TEXT NULL", $jobs ),
            'scopeInterior'        => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN scopeInterior TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'scopeExterior'        => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN scopeExterior TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'custServiceState'     => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN custServiceState VARCHAR(2) NOT NULL DEFAULT ''", $jobs ),
            'master_notes'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN master_notes LONGTEXT NULL", $jobs ),
            'exclude_from_reports' => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN exclude_from_reports TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'not_won'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN not_won TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'no_materials_needed'  => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN no_materials_needed TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'review_dismissed'     => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN review_dismissed TINYINT(1) NOT NULL DEFAULT 0", $jobs ),
            'notified_worker_ids'  => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN notified_worker_ids TEXT NULL", $jobs ),
            'crew_id'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN crew_id BIGINT UNSIGNED NOT NULL DEFAULT 0", $jobs ),
        ];
        foreach ( $job_columns as $column => $sql ) {
            if ( !$column_exists( $jobs, $column ) ) {
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every value in the array above is built with $wpdb->prepare() and a %i identifier
                $wpdb->query( $sql );
            }
        }
        if ( $column_exists( $jobs, 'customer_id' ) ) {
            $customer_id_index_exists = (bool) $wpdb->get_var( $wpdb->prepare( "SHOW INDEX FROM %i WHERE Key_name = %s", $jobs, 'customer_id' ) );
            if ( !$customer_id_index_exists ) {
                $wpdb->query( $wpdb->prepare( "ALTER TABLE %i ADD KEY customer_id (customer_id)", $jobs ) );
            }
        }
        if ( $column_exists( $jobs, 'parent_job_id' ) ) {
            $parent_job_id_index_exists = (bool) $wpdb->get_var( $wpdb->prepare( "SHOW INDEX FROM %i WHERE Key_name = %s", $jobs, 'parent_job_id' ) );
            if ( !$parent_job_id_index_exists ) {
                $wpdb->query( $wpdb->prepare( "ALTER TABLE %i ADD KEY parent_job_id (parent_job_id)", $jobs ) );
            }
        }
        $estimates = paintops_table( 'estimates' );
        $estimate_columns = [
            'status'                     => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN status VARCHAR(30) NOT NULL DEFAULT 'draft'", $estimates ),
            'accepted_date'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN accepted_date DATE NULL", $estimates ),
            'declined_date'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN declined_date DATE NULL", $estimates ),
            'converted_to_job'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN converted_to_job TINYINT(1) NOT NULL DEFAULT 0", $estimates ),
            'public_token'               => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN public_token VARCHAR(64) NULL", $estimates ),
            'deposit_paid'               => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN deposit_paid TINYINT(1) NOT NULL DEFAULT 0", $estimates ),
            'deposit_paid_at'            => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN deposit_paid_at DATETIME NULL", $estimates ),
            'stripe_checkout_session_id' => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN stripe_checkout_session_id VARCHAR(120) NULL", $estimates ),
            'company_id'                 => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN company_id BIGINT UNSIGNED NOT NULL DEFAULT 1", $estimates ),
            'show_itemized'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN show_itemized TINYINT(1) NOT NULL DEFAULT 0", $estimates ),
            'scope_notes'                => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN scope_notes TEXT NULL", $estimates ),
            'viewed_at'                  => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN viewed_at DATETIME NULL", $estimates ),
            'view_count'                 => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN view_count INT(11) NOT NULL DEFAULT 0", $estimates ),
            'accepted_name'              => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN accepted_name VARCHAR(190) NULL", $estimates ),
            'accepted_at'                => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN accepted_at DATETIME NULL", $estimates ),
            'accepted_ip'                => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN accepted_ip VARCHAR(45) NULL", $estimates ),
            'declined_reason'            => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN declined_reason TEXT NULL", $estimates ),
        ];
        foreach ( $estimate_columns as $column => $sql ) {
            if ( !$column_exists( $estimates, $column ) ) {
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every value in the array above is built with $wpdb->prepare() and a %i identifier
                $wpdb->query( $sql );
            }
        }
        // Customer's service-address state, see the matching custServiceState
        // column on the jobs table above. Kept in sync here too so an existing
        // customer record picked on the job form (or matched by email/phone in
        // paintops_find_or_create_customer()) actually has somewhere to store
        // it, instead of the value only ever surviving on whichever one job it
        // was typed into.
        $customers = paintops_table( 'customers' );
        if ( !$column_exists( $customers, 'service_state' ) ) {
            $wpdb->query( $wpdb->prepare( "ALTER TABLE %i ADD COLUMN service_state VARCHAR(2) NOT NULL DEFAULT ''", $customers ) );
        }
        // Job Photos: a free-text caption ("notes") plus whatever metadata
        // the photo itself already carried at capture time, see
        // paintops_extract_photo_exif() in job-photos.php. taken_at/gps_lat/
        // gps_lng only ever get populated when the phone/camera actually
        // embedded that EXIF data (many phones strip GPS by default for
        // privacy), so all three stay NULL, and simply don't render, on
        // any photo that didn't have it. Existing photos uploaded before
        // this shipped keep all four columns NULL/empty, which is exactly
        // how they already looked (no notes, no captured metadata).
        $photos = paintops_table( 'job_photos' );
        $photo_columns = [
            'notes'           => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN notes TEXT NULL", $photos ),
            'taken_at'        => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN taken_at DATETIME NULL", $photos ),
            'gps_lat'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN gps_lat DECIMAL(10,7) NULL", $photos ),
            'gps_lng'         => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN gps_lng DECIMAL(10,7) NULL", $photos ),
            'photo_role'      => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN photo_role VARCHAR(10) NULL", $photos ),
            'paired_photo_id' => $wpdb->prepare( "ALTER TABLE %i ADD COLUMN paired_photo_id BIGINT UNSIGNED NULL", $photos ),
        ];
        foreach ( $photo_columns as $column => $sql ) {
            if ( !$column_exists( $photos, $column ) ) {
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every value in the array above is built with $wpdb->prepare() and a %i identifier
                $wpdb->query( $sql );
            }
        }
        if ( !$column_exists( $estimates, 'public_token' ) ) {
            return;
            // ALTER above failed silently somehow; index add below would too.
        }
        $index_exists = (bool) $wpdb->get_var( $wpdb->prepare( "SHOW INDEX FROM %i WHERE Key_name = %s", $estimates, 'public_token' ) );
        if ( !$index_exists ) {
            $wpdb->query( $wpdb->prepare( "ALTER TABLE %i ADD KEY public_token (public_token)", $estimates ) );
        }
    }

    /**
     * Locate an existing PaintOps app page.
     */
    function paintops_find_app_page_id() : int {
        $stored_id = (int) get_option( 'paintops_app_page_id', 0 );
        if ( $stored_id > 0 ) {
            $post = get_post( $stored_id );
            if ( $post instanceof WP_Post && $post->post_type === 'page' && $post->post_status !== 'trash' ) {
                return $stored_id;
            }
        }
        $po_app_page_query_args = [
            'post_type'      => 'page',
            'post_status'    => ['publish', 'draft', 'private'],
            'posts_per_page' => 1,
            'fields'         => 'ids',
            'no_found_rows'  => true,
        ];
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-time recovery lookup for the single app page, runs only when the stored page id is missing, result is cached back into the option
        $po_app_page_query_args['meta_key'] = '_paintops_app_page';
        $po_app_page_query_args['meta_value'] = '1';
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        $meta_query = new WP_Query($po_app_page_query_args);
        if ( !empty( $meta_query->posts[0] ) ) {
            $page_id = (int) $meta_query->posts[0];
            update_option( 'paintops_app_page_id', $page_id );
            return $page_id;
        }
        $existing = get_page_by_path( 'paintops', OBJECT, 'page' );
        if ( $existing instanceof WP_Post && $existing->post_status !== 'trash' ) {
            update_option( 'paintops_app_page_id', (int) $existing->ID );
            update_post_meta( (int) $existing->ID, '_paintops_app_page', '1' );
            if ( strpos( (string) $existing->post_content, '[paintops_app]' ) === false ) {
                wp_update_post( [
                    'ID'           => (int) $existing->ID,
                    'post_content' => '[paintops_app]',
                ] );
            }
            return (int) $existing->ID;
        }
        return 0;
    }

    /**
     * A short label for whichever section of the app is currently being
     * viewed, based on the same query args paintops_fetch_csjobs() itself branches
     * on. Kept independent of paintops_render_app_nav()'s $active_nav
     * value on purpose, this only needs to be simple and always defined
     * (it can't do a DB lookup to say e.g. "Edit Job #12", since the theme
     * renders it before any of that data is fetched), whereas the content
     * area below it can still show that level of detail in its own <h2>.
     */
    function paintops_current_section_label() : string {
        if ( isset( $_GET['resources'] ) ) {
            return 'Resources';
        }
        if ( isset( $_GET['calendar'] ) ) {
            return 'Calendar';
        }
        if ( isset( $_GET['customers'] ) ) {
            return 'Customers';
        }
        if ( isset( $_GET['profits'] ) || isset( $_GET['jobRanks'] ) ) {
            return 'Profits';
        }
        if ( isset( $_GET['timeOff'] ) ) {
            return 'Manage Time Off';
        }
        if ( isset( $_GET['business'] ) ) {
            return 'Business';
        }
        // Estimates flow: distinguish viewing, creating-for-a-job (replace),
        // revising, and a brand-new quote so each shows its own title rather
        // than the generic "Jobs".
        if ( isset( $_GET['estimates'] ) ) {
            $csaction = ( isset( $_GET['csaction'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['csaction'] ) ) : '' );
            if ( isset( $_GET['viewID'] ) ) {
                return 'View Quote';
            }
            if ( $csaction === 'create' ) {
                // Creating a quote for a job (with or without a jobID) is a New
                // Quote; the on-page heading matches. Only revising an existing
                // estimate (editEid) is a revision.
                if ( isset( $_GET['editEid'] ) ) {
                    return 'Revise Quote';
                }
                return 'New Quote';
            }
            return 'Quotes';
        }
        // Vehicle log; a job passed in (?vjob=) means logging an expense
        // against that specific job.
        if ( isset( $_GET['vehicle'] ) ) {
            return ( isset( $_GET['vjob'] ) && (int) $_GET['vjob'] > 0 ? 'Log Vehicle Expense' : 'Vehicle Log' );
        }
        if ( isset( $_GET['jobFinancials'] ) ) {
            return 'Job Financials';
        }
        if ( isset( $_GET['jobPhotos'] ) ) {
            return 'Job Photos';
        }
        if ( isset( $_GET['jobAddReceipt'] ) ) {
            return 'Add Receipt';
        }
        if ( isset( $_GET['scheduleFollowUp'] ) ) {
            return 'Schedule a Callback / Punch List Visit';
        }
        if ( isset( $_GET['newJob'] ) ) {
            return 'New Request';
        }
        if ( isset( $_GET['editEid'] ) ) {
            return 'Edit Request';
        }
        // Default job list, single-job view, Paint Colors, Pipeline, etc.
        // all remain part of the "Jobs" flow.
        return 'Jobs';
    }

    /**
     * Make the app's WP Page title, rendered by the theme as its own
     * <h1>, above the PaintOps nav bar, reflect the section currently
     * being viewed instead of the same static "PaintOps" on every route.
     * Scoped tightly to just this one page ID, so it never touches any
     * other page's title.
     *
     * Two filters, not one: WordPress themes render a page's title one of
     * two common ways, calling the_title()/get_the_title() from inside
     * the main Loop (filtered by 'the_title'), or calling
     * single_post_title() for a separate page-header/banner area outside
     * the Loop (filtered by 'single_post_title' instead, with the WP_Post
     * object rather than just an ID). The 'the_title' hook REQUIRES
     * in_the_loop(): nav menus, wp_list_pages()/wp_page_menu() fallbacks,
     * and widgets all run this same filter OUTSIDE the Loop, and without
     * the guard a menu containing the app page showed the current screen
     * name ("Calendar") as the menu label with aria-current styling
     * (found live on the creditwww free-build test, 2026-07-28). Theme
     * hero banners outside the Loop are covered by the unrestricted
     * 'single_post_title' hook below, which menus never call.
     */
    add_filter(
        'the_title',
        function ( $title, $post_id = 0 ) {
            if ( is_admin() || !in_the_loop() || !function_exists( 'paintops_find_app_page_id' ) ) {
                return $title;
            }
            if ( (int) $post_id !== paintops_find_app_page_id() ) {
                return $title;
            }
            return paintops_current_section_label();
        },
        10,
        2
    );
    add_filter(
        'single_post_title',
        function ( $title, $post = null ) {
            if ( is_admin() || !function_exists( 'paintops_find_app_page_id' ) ) {
                return $title;
            }
            $post_id = ( is_object( $post ) ? (int) ($post->ID ?? 0) : 0 );
            if ( $post_id !== paintops_find_app_page_id() ) {
                return $title;
            }
            return paintops_current_section_label();
        },
        10,
        2
    );
    /**
     * Create the PaintOps front-end app page when it is missing.
     */
    function paintops_create_app_page() : int {
        $author_id = get_current_user_id();
        if ( !$author_id ) {
            $admins = get_users( [
                'role'   => 'administrator',
                'number' => 1,
                'fields' => 'ID',
            ] );
            $author_id = ( !empty( $admins[0] ) ? (int) $admins[0] : 1 );
        }
        $page_id = wp_insert_post( [
            'post_title'     => 'PaintOps',
            'post_name'      => 'paintops',
            'post_status'    => 'publish',
            'post_type'      => 'page',
            'post_content'   => '[paintops_app]',
            'post_author'    => $author_id,
            'comment_status' => 'closed',
            'ping_status'    => 'closed',
        ], true );
        if ( is_wp_error( $page_id ) || !$page_id ) {
            update_option( 'paintops_app_page_last_error', ( is_wp_error( $page_id ) ? $page_id->get_error_message() : 'Unknown page creation error' ) );
            return 0;
        }
        update_option( 'paintops_app_page_id', (int) $page_id );
        delete_option( 'paintops_app_page_last_error' );
        update_post_meta( (int) $page_id, '_paintops_app_page', '1' );
        return (int) $page_id;
    }

    /**
     * Return the PaintOps front-end app page ID, creating it when needed.
     */
    function paintops_get_app_page_id() : int {
        $page_id = paintops_find_app_page_id();
        if ( $page_id > 0 ) {
            return $page_id;
        }
        return paintops_create_app_page();
    }

    /**
     * Make sure the app page exists during normal admin/plugin loading.
     */
    function paintops_ensure_app_page() : void {
        if ( is_admin() && current_user_can( 'manage_options' ) ) {
            paintops_get_app_page_id();
        }
    }

    /**
     * Front controller for the notification-trigger cron event.
     * Defined here so it can be referenced before job-manager-notifications.php
     * loads; the actual implementation lives there.
     */
    add_action( 'paintops_daily_notifications', function () {
        if ( function_exists( 'paintops_run_job_notifications' ) ) {
            paintops_run_job_notifications();
        }
    } );
    /**
     * Return the front-end app URL.
     */
    function paintops_app_url(  array $args = []  ) : string {
        $page_id = paintops_get_app_page_id();
        if ( $page_id > 0 ) {
            $url = get_permalink( $page_id );
        } else {
            $url = home_url( '/' );
        }
        if ( !empty( $args ) ) {
            $url = add_query_arg( $args, $url );
        }
        return $url;
    }

    /**
     * Build PaintOps URLs.
     *
     * Inside WP Admin, keep users in admin.php?page=paintops.
     * On the front-end app page, use the current page URL.
     */
    function paintops_admin_url(  array $args = []  ) : string {
        if ( is_admin() ) {
            $base_args = [
                'page' => 'paintops',
            ];
            return admin_url( 'admin.php?' . http_build_query( array_merge( $base_args, $args ) ) );
        }
        return paintops_app_url( $args );
    }

    // Escaped app URL for hrefs so in-app links keep the app page on plain permalinks.
    function paintops_href(  array $args  ) : string {
        return esc_url( paintops_admin_url( $args ) );
    }

    /**
     * Logged-out gate box with a working Log In link (Mike 2026-07-24: a new
     * painter's job email told him to log in but the gate gave him nothing
     * to tap, so he was stuck). The login form sends him back to the exact
     * app view he tried to open, so a worker tapping a job email lands on
     * that job right after signing in.
     */
    function paintops_login_required_box() : string {
        $paintops_back = ( function_exists( 'paintops_app_url' ) ? paintops_app_url() : home_url( '/' ) );
        $paintops_view_id = absint( wp_unslash( $_GET['viewEid'] ?? 0 ) );
        if ( $paintops_view_id > 0 ) {
            $paintops_back = add_query_arg( 'viewEid', $paintops_view_id, $paintops_back );
        }
        $paintops_login_url = wp_login_url( $paintops_back );
        return '<div class="paintops-login-required" style="max-width:420px;margin:48px auto;padding:28px 24px;border:1px solid #dfe5ec;border-radius:12px;background:#fff;text-align:center;box-shadow:0 2px 10px rgba(20,40,70,0.06);">' . '<p style="margin:0 0 6px;font-size:17px;font-weight:700;color:#1f2d3d;">Please log in to access PaintOps</p>' . '<p style="margin:0 0 18px;font-size:14px;color:#556677;">Sign in with your team username and password. You will land right back where you were headed.</p>' . '<p style="margin:0;"><a href="' . esc_url( $paintops_login_url ) . '" style="display:inline-block;background:#2f5aae;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 28px;border-radius:8px;">Log In</a></p>' . '</div>';
    }

    /**
     * Render the main PaintOps app.
     */
    function paintops_render_app() : string {
        // Only turn away visitors who are not logged in at all. Every
        // non-staff route from here (a job link, photos, a receipt,
        // financials, and the bare-URL "My Jobs" home) is already gated
        // inside paintops_fetch_csjobs() itself, which re-derives
        // is_user_logged_in() && !paintops_user_can_manage() and checks each
        // action's specific per-job or per-user permission (see its own
        // comment) before showing anything, so there's nothing extra to
        // duplicate here.
        //
        // 2026-07-25 fix: this used to also require a Painter/Helper's
        // request to look like a specific job link (?viewEid=) before letting
        // them through, so a worker who opened the bare app URL, exactly what
        // paintops_fetch_csjobs()'s "My Jobs" home (job-view.php,
        // paintops_render_worker_home(), added 2026-07-19) was built to
        // handle, saw "Please log in to access PaintOps" while already
        // logged in. That extra condition is removed; any logged-in user now
        // reaches paintops_fetch_csjobs() and gets routed from there.
        if ( !is_user_logged_in() ) {
            return paintops_login_required_box();
        }
        if ( function_exists( 'paintops_fetch_csjobs' ) ) {
            return paintops_fetch_csjobs();
        }
        return '<p>PaintOps could not be loaded.</p>';
    }

    /**
     * Shortcode used by the auto-created front-end page.
     */
    function paintops_app_shortcode() : string {
        return paintops_render_app();
    }

    add_shortcode( 'paintops_app', 'paintops_app_shortcode' );
    /**
     * Never let the browser, a proxy, or a host-level page cache serve a
     * stale copy of any PaintOps app page. This is an authenticated internal
     * business tool where every screen is query-string-routed off the same
     * one page (?estimates=1, ?jobFinancials=X, etc.), a cached response
     * here doesn't just show old content, it can show one staff member's
     * in-progress form state to the next person who loads the same URL, or
     * mask a real code/data change behind an old cached render while
     * debugging something (like the Paint Calculator's PHP rewrite, if a
     * cached copy of this page were ever served, a fix here could look like
     * it "didn't take"). Hooked on template_redirect, which fires after WP
     * has resolved the request to a specific page but before any theme
     * output has started, so nocache_headers() can still actually set
     * headers, calling it later, e.g. inside the shortcode itself, would
     * be too late (the theme's own <head> output would already be underway).
     */
    function paintops_send_nocache_headers_for_app() : void {
        if ( !is_singular() ) {
            return;
        }
        $post = get_post();
        if ( !$post || !has_shortcode( (string) $post->post_content, 'paintops_app' ) ) {
            return;
        }
        nocache_headers();
    }

    add_action( 'template_redirect', 'paintops_send_nocache_headers_for_app' );
    /**
     * The "App Page Display" setting: whether the active theme's own header and
     * footer wrap the PaintOps app page ON MOBILE. 'off' shows them everywhere
     * (normal site), 'mobile' hides them on phones/small devices only. Desktop
     * ALWAYS keeps the theme chrome: the former third value 'all' (hide
     * everywhere) was RETIRED 2026-07-19 (Mike's call, a full-bleed edge-to-edge
     * app on a wide monitor reads badly; the theme's page width is the desktop
     * container). A stored legacy 'all' is normalized to 'mobile' here, which
     * preserves the mobile half of what that admin had asked for. Set under
     * PaintOps > Settings > Appearance > App Page Display, seeded to 'mobile'
     * for brand-new installs and 'off' for existing ones (see
     * paintops_seed_default_options).
     */
    function paintops_app_chrome_mode() : string {
        $mode = (string) get_option( 'paintops_app_chrome_mode', 'off' );
        if ( $mode === 'all' ) {
            return 'mobile';
        }
        return ( in_array( $mode, array('off', 'mobile'), true ) ? $mode : 'off' );
    }

    /**
     * Whether the theme header/footer should be hidden for THIS request. The
     * app page is served no-cache (see paintops_send_nocache_headers_for_app),
     * so a per-device wp_is_mobile() check is safe and won't be cached across
     * phone and desktop visitors.
     */
    /**
     * Desktop half of App Page Display (added 2026-07-22, Mike's request; a
     * separate option so the mobile default and desktop default can differ:
     * hide on phones, show on desktops).
     */
    function paintops_app_chrome_desktop_mode() : string {
        $mode = (string) get_option( 'paintops_app_chrome_desktop', 'show' );
        return ( in_array( $mode, array('show', 'hide'), true ) ? $mode : 'show' );
    }

    function paintops_app_chrome_hidden() : bool {
        $is_mobile = ( function_exists( 'wp_is_mobile' ) ? wp_is_mobile() : false );
        if ( $is_mobile ) {
            return paintops_app_chrome_mode() === 'mobile';
        }
        return paintops_app_chrome_desktop_mode() === 'hide';
    }

    /**
     * True when the current front-end request is the singular app page (the page
     * holding the [paintops_app] shortcode). Shared by the canvas renderer below.
     */
    function paintops_is_app_page_request() : bool {
        if ( is_admin() || is_feed() || is_embed() ) {
            return false;
        }
        if ( !is_singular() ) {
            return false;
        }
        $post = get_post();
        return $post instanceof WP_Post && has_shortcode( (string) $post->post_content, 'paintops_app' );
    }

    /**
     * When the app page is loaded and the App Page Display setting says to hide
     * the site chrome, render the page ourselves as a minimal full-bleed canvas:
     * the theme's enqueued styles/scripts still load (via wp_head/wp_footer) so
     * the app looks the same, but the theme's own header, navigation, and footer
     * are left out, giving crews the whole screen. Rendered on template_redirect
     * and exited so the theme's page template never runs.
     *
     * Priority 5 keeps this after the report/export handlers (priority 1, which
     * render and exit for their own ?param requests) but before normal template
     * loading, so those special routes are untouched.
     */
    function paintops_render_app_canvas_if_hidden() : void {
        if ( is_admin() || !paintops_is_app_page_request() || !paintops_app_chrome_hidden() ) {
            return;
        }
        nocache_headers();
        // Mark that we're inside the canvas so paintops_canvas_close_on_get_footer()
        // knows to swallow the theme footer. The app ends most of its routes with
        // `if (!is_admin()) { get_footer(); exit; }` (see paintops_fetch_csjobs in
        // manage-jobs.php), which runs during the_content() below, i.e. inside this
        // canvas; without the interceptor those calls would emit the theme footer
        // even in header/footer-off mode (the "footer showed after submit" bug).
        $GLOBALS['paintops_canvas_active'] = true;
        ?><!doctype html>
<html <?php 
        language_attributes();
        ?>>
<head>
<meta charset="<?php 
        bloginfo( 'charset' );
        ?>">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<?php 
        paintops_enqueue_style( 'app-canvas' );
        wp_head();
        ?>
</head>
<body <?php 
        body_class( 'paintops-app-canvas' );
        ?>>
<div class="paintops-app-canvas-inner">
<?php 
        while ( have_posts() ) {
            the_post();
            the_content();
        }
        // Reached only when the current route RETURNED its content (e.g. the quote
        // form) rather than ending itself with get_footer()+exit. Close the canvas
        // the same way the interceptor does.
        ?>
</div>
<?php 
        wp_footer();
        ?>
</body>
</html>
<?php 
        exit;
    }

    add_action( 'template_redirect', 'paintops_render_app_canvas_if_hidden', 5 );
    /**
     * The app finishes most of its front-end routes with `get_footer(); exit;`,
     * which is fine when the theme chrome is shown but would print the theme's
     * footer inside our chrome-off canvas. get_footer() fires the 'get_footer'
     * action before it loads the theme's footer.php, so when the canvas is active
     * we close the canvas ourselves here and exit, and footer.php never loads.
     * This covers every route's get_footer() call in one place. Guarded so it only
     * ever fires once and only while the canvas is active.
     */
    function paintops_canvas_close_on_get_footer(  $name = null  ) : void {
        if ( empty( $GLOBALS['paintops_canvas_active'] ) ) {
            return;
        }
        static $done = false;
        if ( $done ) {
            return;
        }
        $done = true;
        echo "\n</div>\n";
        wp_footer();
        echo "\n</body>\n</html>";
        exit;
    }

    add_action( 'get_footer', 'paintops_canvas_close_on_get_footer' );
    /**
     * Copy each .cs-fin-table's column headers onto its body cells as data-label
     * attributes, so the mobile "stacked card" table layout (see job-style.php)
     * can show a label beside every value without every financial table across the
     * plugin carrying data-labels in its markup. Printed once in the front-end app
     * page footer, after the tables exist in the DOM. Handles nested tables and
     * colspan cells (a row-spanning cell is skipped and the index advances).
     */
    function paintops_cs_fin_table_labelize() : void {
        if ( is_admin() || !function_exists( 'paintops_is_app_page_request' ) || !paintops_is_app_page_request() ) {
            return;
        }
        paintops_enqueue_script( 'app-fin-table-labels' );
    }

    add_action( 'wp_footer', 'paintops_cs_fin_table_labelize' );
    /**
     * Belt-and-suspenders for hiding the theme's page-title H1 on app pages
     * (2026-07-19, the "duplicate titles" report). job-style.php hides the
     * title for all KNOWN theme markup with CSS, but a theme with wrapper
     * classes not on that list would still print it, and (via the
     * section-label filters above) it would exactly duplicate the app's own
     * screen heading. This script hides any H1 whose text is exactly the
     * current section label. That can only be the theme's injected title:
     * the app's own headings are H2s, or H1s with different text (the
     * calendar's "July 2026", job view's "Job #12 ...", the financials
     * coversheet). Keep it that way, never add an app H1 whose text equals
     * paintops_current_section_label() for its route. The document <title>
     * is unaffected.
     */
    function paintops_hide_theme_title_dup() : void {
        if ( is_admin() || !function_exists( 'paintops_is_app_page_request' ) || !paintops_is_app_page_request() ) {
            return;
        }
        $label = ( function_exists( 'paintops_current_section_label' ) ? paintops_current_section_label() : '' );
        if ( $label === '' ) {
            return;
        }
        ob_start();
        ?>
    (function(){
        var lbl = <?php 
        echo wp_json_encode( $label );
        ?>;
        function run(){
            var h1s = document.querySelectorAll('h1');
            for (var i = 0; i < h1s.length; i++) {
                if (h1s[i].textContent.trim() === lbl) {
                    h1s[i].style.display = 'none';
                }
            }
        }
        if (document.readyState !== 'loading') { run(); }
        else { document.addEventListener('DOMContentLoaded', run); }
    })();
    <?php 
        paintops_add_inline_script( (string) ob_get_clean() );
    }

    add_action( 'wp_footer', 'paintops_hide_theme_title_dup' );
    /**
     * Render a lean WP admin dashboard.
     */
    function paintops_render_admin_page() : void {
        if ( !paintops_user_can_manage() ) {
            wp_die( esc_html__( 'You do not have permission to access PaintOps.', 'paintops' ) );
        }
        $app_url = esc_url( paintops_app_url() );
        echo '<div class="wrap paintops-admin-wrap">';
        echo '<h1>PaintOps</h1>';
        echo '<p>Your main PaintOps workspace is available as a regular WordPress page. This keeps the plugin setup simple and avoids a crowded WP admin menu.</p>';
        echo wp_kses( '<p><a class="button button-primary button-hero" href="' . esc_url( $app_url ) . '">Open PaintOps Dashboard</a></p>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo wp_kses( '<p><strong>App page:</strong> <a href="' . esc_url( $app_url ) . '">' . esc_html( $app_url ) . '</a></p>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '</div>';
    }

    /**
     * Render settings.
     */
    /**
     * Shared CSS for the collapsible-accordion look used on both PaintOps
     * admin settings screens (Settings and Business), kept in one place so
     * the two pages can't drift out of sync with each other.
     *
     * .paintops-settings-section / -body / -summary-meta: top-level
     * collapsible sections (one per major settings group).
     * .paintops-settings-info: a blue callout box explaining where a field
     * or section actually shows up.
     * .paintops-settings-entry / -entry-body / -entry-meta: smaller nested
     * collapsibles for individual list rows (e.g. one per scope term).
     */
    function paintops_echo_settings_accordion_styles() : void {
        paintops_enqueue_style( 'settings-accordion' );
    }

    /**
     * Builds the right-hand side of a top-level settings <summary>, the
     * optional status/count preview plus the dropdown arrow, always grouped
     * and pushed flush right via .paintops-settings-summary-right's own
     * margin-left:auto. This one wrapper is what keeps the preview text
     * lined up consistently next to the arrow regardless of how long the
     * section title to its left is, putting the meta span directly in
     * <summary> alongside the title (the previous approach) left its
     * position to float wherever flexbox's 3-way space-between happened to
     * land it. $meta_html may be '' for a section with nothing to preview.
     */
    function paintops_settings_summary_right(  string $meta_html = ''  ) : string {
        $meta = ( $meta_html !== '' ? '<span class="paintops-settings-summary-meta">' . $meta_html . '</span>' : '' );
        return '<span class="paintops-settings-summary-right">' . $meta . '</span>';
    }

    /**
     * Human-readable label for the site's current timezone setting: the named
     * zone (e.g. "America/New_York") when one is chosen, otherwise the manual
     * UTC offset formatted as "UTC+0" / "UTC-4:30". Returns [$label, $is_named]
     * so callers can warn about a fixed offset (which does not track DST).
     */
    function paintops_timezone_label() : array {
        $tz_string = (string) get_option( 'timezone_string', '' );
        if ( $tz_string !== '' ) {
            return [$tz_string, true];
        }
        $offset = (float) get_option( 'gmt_offset', 0 );
        $sign = ( $offset < 0 ? '-' : '+' );
        $abs = abs( $offset );
        $hours = (int) $abs;
        $mins = (int) round( ($abs - $hours) * 60 );
        $label = 'UTC' . $sign . $hours . (( $mins ? ':' . str_pad(
            (string) $mins,
            2,
            '0',
            STR_PAD_LEFT
        ) : '' ));
        return [$label, false];
    }

    /* The Date & Time status now lives inside the Setup Health board, see
     * paintops_render_setup_health_section(). The old standalone section
     * renderer was removed when Settings was consolidated (2026-07-17). */
    /**
     * Whether the site is served securely, checked robustly so a site that IS
     * on HTTPS behind a reverse proxy / CDN, where PHP's is_ssl() can wrongly
     * report HTTP, is not falsely flagged. Any reliable signal counts as
     * secure: is_ssl(), the admin's configured home/site URL scheme, or a
     * forwarded-proto header set by the proxy. Erring toward "secure" keeps
     * us from nagging a correctly-configured HTTPS site (warn, never block).
     */
    function paintops_site_is_secure() : bool {
        if ( is_ssl() ) {
            return true;
        }
        if ( strpos( (string) get_option( 'home' ), 'https://' ) === 0 || strpos( (string) get_option( 'siteurl' ), 'https://' ) === 0 ) {
            return true;
        }
        if ( !empty( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) ) === 'https' ) {
            return true;
        }
        if ( !empty( $_SERVER['HTTP_X_FORWARDED_SSL'] ) && strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_X_FORWARDED_SSL'] ) ) ) === 'on' ) {
            return true;
        }
        return false;
    }

    /**
     * The PaintOps features that do not work over plain HTTP, listed for the
     * insecure-site warning. These are real functional limits (the browser
     * and Stripe enforce them), not just best-practice nagging.
     */
    function paintops_https_impacted_features() : array {
        return [
            'The offline app and its automatic sync: service workers only run over HTTPS, so field crews lose offline job access and the offline time clock.',
            'Installing PaintOps to a phone\'s home screen ("Add to Home Screen"): browsers only offer this on secure sites.',
            'Collecting Stripe deposits and online payments: Stripe refuses live payments over an insecure connection.',
            'Encrypting logins, customer details, and payment info in transit: on plain HTTP this data travels unencrypted.'
        ];
    }

    /* The Security (HTTPS) status now lives inside the Setup Health board, see
     * paintops_render_setup_health_section(). The old standalone section
     * renderer was removed when Settings was consolidated (2026-07-17).
     * paintops_site_is_secure() and paintops_https_impacted_features() above
     * are still the single source of truth and are reused by the board and by
     * the admin-notice nudge below. */
    /**
     * A non-blocking admin notice on PaintOps screens when the site isn't
     * secure, so the warning is seen without opening Settings. Scoped to
     * PaintOps pages to stay out of the way everywhere else.
     */
    function paintops_https_admin_notice() : void {
        if ( paintops_site_is_secure() || !current_user_can( 'manage_options' ) ) {
            return;
        }
        $screen = ( function_exists( 'get_current_screen' ) ? get_current_screen() : null );
        if ( !$screen || strpos( (string) $screen->id, 'paintops' ) === false ) {
            return;
        }
        $settings_url = admin_url( 'admin.php?page=paintops-settings' );
        echo '<div class="notice notice-error"><p><strong>PaintOps:</strong> your site is not served over HTTPS. The offline app, home-screen install, and Stripe payments will not work, and customer and payment data is not encrypted in transit. <a href="' . esc_url( $settings_url ) . '">See details and what to do</a>.</p></div>';
    }

    add_action( 'admin_notices', 'paintops_https_admin_notice' );
    /**
     * Quiet room: hide OTHER plugins' admin notices on PaintOps screens.
     *
     * Mike's Painters/Helpers page was buried under a Persistent Login trial
     * banner, a Forminator rating nag, and two Rank Math upsells before the
     * actual page content started (2026-07-20). Every notice-spamming plugin
     * hooks admin_notices/all_admin_notices, so on our own pages we clear
     * those hooks before admin-header.php fires them, then re-add the ONE
     * notice PaintOps itself registers (the HTTPS warning above). PaintOps'
     * page-level feedback is echoed inline by the render functions, not via
     * admin_notices, so it is unaffected.
     *
     * Freemius-rendered subpages (Account, Pricing, Contact...) are excluded:
     * Freemius shows its own "license activated" style confirmations through
     * admin_notices on those pages and silencing them would eat real
     * feedback. Everywhere else under our menu, the room stays quiet.
     */
    function paintops_hide_foreign_admin_notices() : void {
        $page = ( isset( $_GET['page'] ) ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) : '' );
        if ( $page === '' || strpos( $page, 'paintops' ) !== 0 ) {
            return;
        }
        $freemius_pages = [
            'paintops-account',
            'paintops-pricing',
            'paintops-contact',
            'paintops-affiliation',
            'paintops-addons'
        ];
        if ( in_array( $page, $freemius_pages, true ) ) {
            return;
        }
        remove_all_actions( 'admin_notices' );
        remove_all_actions( 'all_admin_notices' );
        remove_all_actions( 'network_admin_notices' );
        remove_all_actions( 'user_admin_notices' );
        add_action( 'admin_notices', 'paintops_https_admin_notice' );
    }

    add_action( 'in_admin_header', 'paintops_hide_foreign_admin_notices' );
    /**
     * Open a specific accordion on the PaintOps admin pages via
     * ?po_open=<slug> (Mike's request 2026-07-22): links from the setup
     * banner and the Help checklist land with the right section already
     * expanded and scrolled into view, instead of a wall of closed
     * <details> the user has to hunt through. Slug = the section's summary
     * text, lowercased, non-alphanumerics collapsed to dashes ("Company
     * Profile" -> company-profile). Purely additive: no param, no effect.
     */
    function paintops_settings_autoopen_script() : void {
        $page = ( isset( $_GET['page'] ) ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) : '' );
        if ( !in_array( $page, ['paintops-settings', 'paintops-business'], true ) || empty( $_GET['po_open'] ) ) {
            return;
        }
        $po_nonce = ( isset( $_GET['po_open_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['po_open_nonce'] ) ) : '' );
        if ( $po_nonce === '' || !wp_verify_nonce( $po_nonce, 'paintops_settings_open' ) ) {
            return;
        }
        $target = sanitize_key( (string) wp_unslash( $_GET['po_open'] ) );
        // Optional second hop (2026-07-22): ?po_entry=<slug> opens a nested
        // details.paintops-settings-entry INSIDE the section too (e.g. the
        // Google Maps key entry inside Setup Health) and scrolls to it, so a
        // message can land the user directly on the field it's talking about.
        $entry = ( !empty( $_GET['po_entry'] ) ? sanitize_key( (string) wp_unslash( $_GET['po_entry'] ) ) : '' );
        paintops_enqueue_script( 'settings-autoopen', 'var paintopsAutoOpenCfg = ' . wp_json_encode( [
            'section' => $target,
            'entry'   => $entry,
        ] ) . ';' );
    }

    add_action( 'admin_enqueue_scripts', 'paintops_settings_autoopen_script' );
    // Builds an admin URL that carries the po_open auto-open nonce above.
    function paintops_settings_open_url(  string $query  ) : string {
        return add_query_arg( 'po_open_nonce', wp_create_nonce( 'paintops_settings_open' ), admin_url( $query ) );
    }

    /**
     * Hide the "Need more sites, custom implementation and dedicated
     * support?" box on the Freemius pricing page (Mike's call, 2026-07-20).
     * Its text is hardcoded in the SDK's minified pricing app (not
     * filterable, and patching vendor files dies on every SDK update), and
     * its contact link points at the Contact page we deliberately disabled
     * in the Freemius menu config, so it 404s. The app renders the box as
     * its own section with a stable class (fs-section--custom-implementation),
     * so page-scoped CSS is the supported-shape fix. PaintOps sells one flat
     * per-site plan; the enterprise "more sites / custom implementation"
     * pitch has no audience here anyway.
     */
    function paintops_hide_pricing_contact_box() : void {
        $page = ( isset( $_GET['page'] ) ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) : '' );
        if ( $page !== 'paintops-pricing' ) {
            return;
        }
        // Second page-scoped rule (2026-07-22, Mike): the pricing app renders
        // every plan from the same template, so the FREE column gets a
        // disabled "Upgrade Now" button too, noise on the plan the user
        // already has. The app marks the free column with a stable
        // .fs-free-plan class; selector verified live on the rendered page.
        paintops_enqueue_style( 'freemius-pricing' );
    }

    add_action( 'admin_enqueue_scripts', 'paintops_hide_pricing_contact_box' );
    /**
     * The items a fresh install still needs an Administrator to fill in
     * before quotes look professional: the identity fields that print on
     * every customer-facing document, plus the published Terms. Shared by
     * the dashboard setup banner and the Help Center checklist so the two
     * can never disagree. Empty array = fully set up.
     */
    function paintops_setup_attention_items() : array {
        if ( !function_exists( 'paintops_company_profile' ) ) {
            return [];
        }
        $profile = paintops_company_profile();
        $business_url = paintops_settings_open_url( 'admin.php?page=paintops-business&po_open=company-profile' );
        $items = [];
        if ( trim( (string) ($profile['name'] ?? '') ) === '' ) {
            $items[] = [
                'label' => 'Business name',
                'hint'  => 'Prints on every quote and invoice.',
                'url'   => $business_url,
            ];
        }
        if ( trim( (string) ($profile['logo'] ?? '') ) === '' ) {
            $items[] = [
                'label' => 'Business logo',
                'hint'  => 'Shown at the top of quotes, invoices, and the customer pages.',
                'url'   => $business_url,
            ];
        }
        if ( trim( (string) ($profile['email'] ?? '') ) === '' ) {
            $items[] = [
                'label' => 'Business email',
                'hint'  => 'Where customer questions and notifications go.',
                'url'   => $business_url,
            ];
        }
        if ( function_exists( 'paintops_quote_terms' ) && trim( paintops_quote_terms() ) === '' ) {
            $items[] = [
                'label' => 'Quote Terms &amp; Conditions',
                'hint'  => 'Published terms customers sign against when accepting.',
                'url'   => $business_url,
            ];
        }
        return $items;
    }

    /**
     * "Finish setting up PaintOps" banner on the Jobs dashboard (Mike's
     * request, 2026-07-21). Administrators only; renders nothing once the
     * core identity items are filled in, and a dismissal (per user, nonce
     * checked) hides it permanently for people who prefer the checklist in
     * Help. The links go straight to the screens that need the attention.
     */
    function paintops_render_setup_banner() : void {
        if ( !current_user_can( 'manage_options' ) ) {
            return;
        }
        if ( isset( $_GET['dismissSetupBanner'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'paintops_dismiss_setup_banner' ) ) {
            update_user_meta( get_current_user_id(), 'paintops_setup_banner_dismissed', '1' );
            return;
        }
        if ( get_user_meta( get_current_user_id(), 'paintops_setup_banner_dismissed', true ) === '1' ) {
            return;
        }
        $items = paintops_setup_attention_items();
        if ( !$items ) {
            return;
            // fully set up, render nothing (house convention)
        }
        $dismiss_url = esc_url( wp_nonce_url( add_query_arg( 'dismissSetupBanner', '1' ), 'paintops_dismiss_setup_banner' ) );
        $help_url = esc_url( paintops_admin_url( [
            'help' => 1,
        ] ) );
        echo '<div style="border:1px solid #e0c36a;background:#fff8e5;border-radius:10px;padding:16px 18px;margin:0 0 16px;">';
        echo '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;">';
        echo '<h3 style="margin:0 0 6px;font-size:16px;">Finish setting up PaintOps</h3>';
        echo wp_kses( '<a href="' . esc_url( $dismiss_url ) . '" style="color:#8a6d3b;font-size:12px;white-space:nowrap;" title="Hide this permanently; the checklist stays available under More &gt; Help">Hide this</a>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '</div>';
        echo '<p style="margin:0 0 10px;font-size:13px;color:#6b5a1f;">A few details are still missing that print on your customer-facing quotes and invoices:</p>';
        echo '<ul style="margin:0 0 12px;padding-left:20px;font-size:14px;">';
        foreach ( $items as $item ) {
            echo wp_kses( '<li style="margin-bottom:4px;"><a href="' . esc_url( $item['url'] ) . '" style="font-weight:600;">' . esc_html( $item['label'] ) . '</a> <span style="color:#8a7f3f;font-size:12px;">' . esc_html( $item['hint'] ) . '</span></li>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        }
        echo '</ul>';
        echo wp_kses( '<a href="' . esc_url( $help_url ) . '" style="font-size:13px;font-weight:600;">Open the full setup checklist &rarr;</a>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '</div>';
    }

    /**
     * The in-app Help page (?help=1, reachable from the More menu; Mike's
     * request 2026-07-21). Everyone gets the Help Center link; only
     * Administrators additionally see the setup checklist with status dots
     * and direct links into wp-admin, since those screens are
     * manage_options-gated anyway.
     */
    function paintops_render_help_center() : void {
        $kb_url = 'https://paintoperations.com/paintops-help-center/';
        echo '<div class="cs-financial-box">';
        echo '<h3 style="margin-top:0;">Help Center</h3>';
        echo '<p style="margin:0 0 12px;">Step-by-step guides for everything in PaintOps: quoting, scheduling, crews, photos, payments, and reports.</p>';
        echo '<p style="margin:0;"><a class="paintops-btn paintops-btn-primary" href="' . esc_url( $kb_url ) . '" target="_blank" rel="noopener noreferrer">Open the Help Center</a></p>';
        echo '</div>';
        if ( !current_user_can( 'manage_options' ) ) {
            return;
        }
        $profile = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
        $business_url = paintops_settings_open_url( 'admin.php?page=paintops-business&po_open=company-profile' );
        $settings_url = admin_url( 'admin.php?page=paintops-settings' );
        $health_url = paintops_settings_open_url( 'admin.php?page=paintops-settings&po_open=setup-health' );
        $stripe_url = paintops_settings_open_url( 'admin.php?page=paintops-settings&po_open=stripe-payments' );
        $openai_url = paintops_settings_open_url( 'admin.php?page=paintops-settings&po_open=ai-settings' );
        // Each row: done?, label, hint, destination. The first four mirror
        // paintops_setup_attention_items(); the rest are the wider setup
        // surface. Stripe/OpenAI rows exist only in the premium build.
        $rows = [
            [
                trim( (string) ($profile['name'] ?? '') ) !== '',
                'Business name',
                'Prints on every quote and invoice.',
                $business_url
            ],
            [
                trim( (string) ($profile['logo'] ?? '') ) !== '',
                'Business logo',
                'Tops your quotes, invoices, and customer pages.',
                $business_url
            ],
            [
                trim( (string) ($profile['email'] ?? '') ) !== '',
                'Business email',
                'Where customer questions and notifications go.',
                $business_url
            ],
            [
                !function_exists( 'paintops_quote_terms' ) || trim( paintops_quote_terms() ) !== '',
                'Quote Terms &amp; Conditions',
                'Customers sign against these when accepting.',
                $business_url
            ],
            [
                !function_exists( 'paintops_maps_api_key' ) || trim( (string) paintops_maps_api_key() ) !== '',
                'Google Maps key',
                'Powers address autocomplete and job maps.',
                $health_url
            ],
            [
                !function_exists( 'paintops_site_is_secure' ) || paintops_site_is_secure(),
                'HTTPS',
                'Required for the phone app, offline mode, and online payments.',
                $health_url
            ]
        ];
        if ( function_exists( 'paintops_stripe_is_configured' ) ) {
            $rows[] = [
                paintops_stripe_is_configured(),
                'Stripe keys',
                'Enables online deposits and invoice payments.',
                $stripe_url
            ];
        }
        if ( function_exists( 'paintops_openai_is_configured' ) ) {
            $rows[] = [
                paintops_openai_is_configured(),
                'OpenAI key',
                'Enables the AI estimating and receipt tools.',
                $openai_url
            ];
        }
        echo '<div class="cs-financial-box">';
        echo '<h3 style="margin-top:0;">Setup checklist</h3>';
        foreach ( $rows as $r ) {
            [
                $done,
                $label,
                $hint,
                $url
            ] = $r;
            $dot = ( $done ? '<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#46b450;flex:none;margin-top:4px;" title="Done"></span>' : '<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#d68000;flex:none;margin-top:4px;" title="Needs attention"></span>' );
            echo '<div style="display:flex;gap:10px;align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--po-card-border,#eee);">';
            echo wp_kses( $dot, paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo wp_kses( '<div style="flex:1;"><strong>' . $label . '</strong><br><span style="font-size:12px;color:var(--po-text-muted,#777);">' . esc_html( $hint ) . '</span></div>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo '<a href="' . esc_url( $url ) . '" style="font-size:13px;font-weight:600;white-space:nowrap;">' . (( $done ? 'Review' : 'Set up' )) . '</a>';
            echo '</div>';
        }
        echo '<p style="margin:12px 0 0;font-size:12px;color:var(--po-text-muted,#777);">The full Setup Health board (app page, email sending, security detail) lives in <a href="' . esc_url( $settings_url ) . '">PaintOps &gt; Settings</a>.</p>';
        echo '</div>';
    }

    /**
     * Setup Health board: one consolidated, status-driven panel at the top of
     * the Settings page. Shows at a glance whether each core piece of the
     * install is set up, and folds the actual control for each item inline so
     * the owner fixes it in place. Replaces the old standalone App Page, Date
     * & Time, Security (HTTPS), Google Maps, Job Notification, and Email
     * Deliverability sections (Mike's call, to thin Settings into a single
     * setup surface and shorten the page). Stripe and AI keep their own
     * sections for now (heavier Pro-gated forms, deferred).
     *
     * Every folded control still POSTs with the SAME field name + nonce it
     * always used, so the save handlers at the top of
     * paintops_render_settings_page() are untouched: only the markup moved.
     *
     * Status per item: 'good' green, 'attention' amber (recommended, take a
     * look), 'blocked' red (a real feature is off), 'optional' gray (fine to
     * leave unset). The summary chip counts attention + blocked only. A
     * 'blocked' item (or the email test result) opens its row automatically so
     * the problem is visible without a click.
     */
    function paintops_render_setup_health_section(
        int $page_id,
        string $app_url,
        string $last_error,
        ?bool $test_email_result
    ) : void {
        list( $tz_label, $tz_named ) = paintops_timezone_label();
        $secure = paintops_site_is_secure();
        $maps_set = paintops_maps_api_key() !== '';
        $notif_on = paintops_notifications_enabled();
        $mail_ok = function_exists( 'paintops_mail_transport_configured' ) && paintops_mail_transport_configured();
        // Per-item status.
        $st_app = ( $last_error ? 'blocked' : 'good' );
        $st_time = ( $tz_named ? 'good' : 'attention' );
        $st_https = ( $secure ? 'good' : 'blocked' );
        $st_offline = ( $secure ? 'good' : 'blocked' );
        $st_maps = ( $maps_set ? 'good' : 'optional' );
        $st_notif = ( $notif_on ? 'good' : 'attention' );
        $st_mail = ( $mail_ok ? 'good' : 'attention' );
        $need = 0;
        foreach ( [
            $st_app,
            $st_time,
            $st_https,
            $st_offline,
            $st_maps,
            $st_notif,
            $st_mail
        ] as $s ) {
            if ( $s === 'attention' || $s === 'blocked' ) {
                $need++;
            }
        }
        if ( $need === 0 ) {
            $chip = '<span style="color:#1a7f37;font-weight:600;">All set</span>';
        } else {
            $has_blocked = $st_app === 'blocked' || $st_https === 'blocked' || $st_offline === 'blocked';
            $chip_color = ( $has_blocked ? '#a94442' : '#8a5a00' );
            $chip = '<span style="color:' . $chip_color . ';font-weight:600;">' . esc_html( (string) $need ) . ' need' . (( $need === 1 ? 's' : '' )) . ' attention</span>';
        }
        $glyph = static function ( string $status ) : string {
            switch ( $status ) {
                case 'good':
                    return '&#10003;';
                case 'attention':
                    return '!';
                case 'blocked':
                    return '&times;';
                default:
                    return '-';
            }
        };
        paintops_enqueue_style( 'settings-health' );
        echo wp_kses( '<details class="paintops-settings-section" open><summary>Setup Health' . paintops_settings_summary_right( $chip ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="po-health-intro">A quick status check of your PaintOps setup. Open any item to fix it right here.</p>';
        // Opens one status row. Force-open a blocked item (or an item flagged by
        // the caller) so a real problem is visible without a click. The status
        // text sits to the right of the name, then the shared entry caret.
        $open_entry = static function (
            string $status,
            string $name,
            string $stat_text,
            bool $force_open = false
        ) use($glyph) {
            $open = ( $status === 'blocked' || $force_open ? ' open' : '' );
            echo wp_kses( '<details class="paintops-settings-entry"' . esc_attr( $open ) . '><summary>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo wp_kses( '<span class="po-health-dot ' . esc_attr( $status ) . '">' . $glyph( $status ) . '</span> ', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo esc_html( $name );
            echo '<span class="po-health-stat">' . esc_html( $stat_text ) . '</span>';
            echo '</summary><div class="paintops-settings-entry-body">';
        };
        $close_entry = static function () {
            echo '</div></details>';
        };
        // --- App Page ---
        $open_entry( $st_app, 'App Page', ( $last_error ? 'Error, see below' : 'Page ID ' . $page_id ) );
        echo '<table class="form-table" role="presentation">';
        echo wp_kses( '<tr><th scope="row">Address</th><td><a href="' . esc_url( $app_url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $app_url ) . '</a>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">Created automatically on activation; holds the <code>[paintops_app]</code> shortcode.</p></td></tr>';
        echo '<tr><th scope="row">Page ID</th><td>' . esc_html( (string) $page_id ) . '</td></tr>';
        if ( $last_error ) {
            echo '<tr><th scope="row">Last Page Error</th><td><code>' . esc_html( (string) $last_error ) . '</code></td></tr>';
        }
        echo '</table>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_recreate_page_action' );
        echo '<p><button type="submit" name="paintops_recreate_page" value="1" class="button">Check / Recreate App Page</button></p>';
        echo '</form>';
        $close_entry();
        // --- Date & Time (timezone) ---
        $open_entry( $st_time, 'Date & Time', $tz_label );
        echo '<p class="description">PaintOps shows every date and time (schedules, the calendar, check-in / check-out, and quote dates) in your site\'s timezone.</p>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row">Current time</th><td><strong>' . esc_html( wp_date( 'l, F j, Y g:i a' ) ) . '</strong></td></tr>';
        echo '<tr><th scope="row">Timezone</th><td>' . esc_html( $tz_label ) . '</td></tr>';
        echo '</table>';
        if ( !$tz_named ) {
            echo '<div class="paintops-settings-info" style="border-left-color:#d68000;background:#fff8ec;color:#8a5a00;">';
            echo '<strong>Heads up:</strong> your timezone is a fixed UTC offset, which does not adjust for daylight saving, so times can be an hour off for part of the year. For accurate local time year-round, pick your city (for example New York, Chicago, Denver, or Los Angeles) instead of an offset.';
            echo '</div>';
        }
        echo '<p>If the time above looks wrong, set it under <a href="' . esc_url( admin_url( 'options-general.php#timezone_string' ) ) . '">Settings &rarr; General &rarr; Timezone</a>: choose your city, then Save Changes. This adjusts times everywhere in WordPress, not just PaintOps.</p>';
        $close_entry();
        // --- Security (HTTPS) ---
        $open_entry( $st_https, 'Security (HTTPS)', ( $secure ? 'Secure' : 'Not secure' ) );
        if ( $secure ) {
            echo '<p>Your site is served over <strong>HTTPS</strong>. Nothing to do: everything in PaintOps works, and customer, login, and payment data is encrypted in transit.</p>';
        } else {
            echo '<div class="paintops-settings-info" style="border-left-color:#a94442;background:#fdecea;color:#8a1f1a;">';
            echo '<strong>Your site is not being served over HTTPS.</strong> PaintOps still runs, but the following will not work until the site is on HTTPS:';
            echo '<ul style="margin:8px 0 0 18px;list-style:disc;">';
            foreach ( paintops_https_impacted_features() as $item ) {
                echo '<li style="margin:4px 0;">' . esc_html( $item ) . '</li>';
            }
            echo '</ul></div>';
            echo '<p>Most hosts offer free SSL (Let\'s Encrypt) with a one-click toggle in your hosting panel, or a plugin such as Really Simple SSL can handle it. If your site actually is on HTTPS and this warning is wrong (some reverse-proxy setups can confuse the check), it\'s safe to ignore.</p>';
        }
        $close_entry();
        // --- Offline app (PWA) ---
        $open_entry( $st_offline, 'Offline app', ( $secure ? 'Ready' : 'Needs HTTPS' ) );
        if ( $secure ) {
            echo '<p>The offline app is <strong>ready</strong>. Crews can install PaintOps to their phone home screen and keep reading jobs (and using the offline time clock) with no signal; queued check-ins sync when they reconnect.</p>';
        } else {
            echo '<p>The offline app and home-screen install need <strong>HTTPS</strong> to run, browsers only register the service worker on a secure site. Fix the Security item above and this turns on automatically.</p>';
        }
        $close_entry();
        // --- Google Maps key ---
        $open_entry( $st_maps, 'Google Maps key', ( $maps_set ? 'Configured' : 'Optional, not set' ) );
        echo '<p class="description">Powers address autocomplete, embedded maps, and geocoding. Directions work without it. <a href="https://console.cloud.google.com/google/maps-apis/credentials" target="_blank" rel="noopener noreferrer">Get / manage a key</a> and restrict it to your site\'s domain.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_maps_key_action' );
        echo '<table class="form-table" role="presentation"><tr><th scope="row"><label for="paintops_maps_api_key">API Key</label></th><td>';
        echo '<input type="text" id="paintops_maps_api_key" name="paintops_maps_api_key" class="regular-text" value="' . esc_attr( paintops_maps_api_key() ) . '">';
        echo '</td></tr></table>';
        echo '<p><button type="submit" name="paintops_save_maps_key" value="1" class="button button-primary">Save Key</button></p>';
        echo '</form>';
        $close_entry();
        // --- Job notifications ---
        $open_entry( $st_notif, 'Job notifications', ( $notif_on ? 'On' : 'Off' ) );
        echo '<p class="description">Sends each assigned Painter/Helper a "job starts tomorrow" reminder email the day before, with the job address, customer, and time.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_notifications_enabled_action' );
        echo '<p><label><input type="checkbox" name="notifications_enabled" value="1"' . checked( $notif_on, true, false ) . '> Send job reminder notifications</label></p>';
        echo '<p><button type="submit" name="paintops_save_notifications_enabled" value="1" class="button button-primary">Save</button></p>';
        echo '</form>';
        $notif_url = esc_url( add_query_arg( [
            'sendNotif' => 1,
            'token'     => paintops_notif_token(),
        ], home_url( '/' ) ) );
        echo '<p class="description">Notifications also run once a day via WP-Cron (when the setting above is on). On a low-traffic site WP-Cron can be unreliable, so you can optionally point an external uptime/cron service at this URL as a backup trigger. Keep it private: anyone with this exact URL (including the token) can trigger it.</p>';
        echo wp_kses( '<p><code>' . esc_html( $notif_url ) . '</code></p>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<form method="post">';
        wp_nonce_field( 'paintops_regen_token_action' );
        echo '<p><button type="submit" name="paintops_regen_token" value="1" class="button" onclick="return confirm(\'This invalidates the current URL. Continue?\');">Regenerate Token</button></p>';
        echo '</form>';
        $close_entry();
        // --- Email deliverability ---
        $open_entry(
            $st_mail,
            'Email deliverability',
            ( $mail_ok ? 'SMTP active' : 'No SMTP detected' ),
            $test_email_result !== null
        );
        echo '<p class="description">PaintOps emails quotes, deposit confirmations, and job reminders through WordPress\'s standard mail system. By default (with no SMTP plugin installed), WordPress sends mail through your web host using PHP\'s built-in <code>mail()</code> function, which most inboxes (Gmail, Outlook, Yahoo, etc.) are increasingly likely to reject or send straight to spam because it isn\'t authenticated. A missed quote or deposit email is a missed job.</p>';
        if ( $mail_ok ) {
            echo '<div class="notice notice-success inline"><p>An SMTP plugin (or other mail customization) appears to be active on this site. This is the recommended setup.</p></div>';
        } else {
            echo '<div class="notice notice-warning inline"><p><strong>No SMTP plugin detected.</strong> We recommend installing a free one and connecting it to your email provider (Gmail, Outlook, your hosting email, etc.) so quotes and notifications reliably reach customers\' inboxes instead of their spam folder. Two solid free options: <a href="https://wordpress.org/plugins/wp-mail-smtp/" target="_blank" rel="noopener noreferrer">WP Mail SMTP</a> or <a href="https://wordpress.org/plugins/post-smtp/" target="_blank" rel="noopener noreferrer">Post SMTP</a>.</p></div>';
        }
        if ( $test_email_result !== null ) {
            if ( $test_email_result ) {
                echo '<div class="notice notice-success inline"><p>Test email sent, check the inbox for your Company Profile email (or the site admin email if none is set). Delivery being accepted here doesn\'t guarantee it landed in the inbox rather than spam, so check both.</p></div>';
            } else {
                echo '<div class="notice notice-error inline"><p>wp_mail() reported failure sending the test email. This usually means your host\'s mail() function is disabled or an SMTP plugin is misconfigured, check your SMTP plugin\'s logs if you have one installed.</p></div>';
            }
        }
        echo '<form method="post">';
        wp_nonce_field( 'paintops_send_test_email_action' );
        echo '<p><button type="submit" name="paintops_send_test_email" value="1" class="button">Send Test Email</button></p>';
        echo '</form>';
        $close_entry();
        echo '</div></details>';
    }

    function paintops_render_settings_page() : void {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to manage PaintOps settings.', 'paintops' ) );
        }
        if ( isset( $_POST['paintops_recreate_page'] ) && check_admin_referer( 'paintops_recreate_page_action' ) ) {
            delete_option( 'paintops_app_page_id' );
            $page_id = paintops_get_app_page_id();
            echo '<div class="notice notice-success"><p>PaintOps app page checked. Page ID: ' . esc_html( (string) $page_id ) . '</p></div>';
        }
        if ( isset( $_POST['paintops_save_maps_key'] ) && check_admin_referer( 'paintops_save_maps_key_action' ) ) {
            update_option( 'paintops_maps_api_key', sanitize_text_field( wp_unslash( $_POST['paintops_maps_api_key'] ?? '' ) ) );
            echo '<div class="notice notice-success"><p>Maps API key saved.</p></div>';
        }
        if ( isset( $_POST['paintops_regen_token'] ) && check_admin_referer( 'paintops_regen_token_action' ) ) {
            update_option( 'paintops_notif_token', wp_generate_password( 32, false, false ) );
            echo '<div class="notice notice-success"><p>Notification trigger token regenerated. Update any external cron/uptime pinger with the new URL below.</p></div>';
        }
        if ( isset( $_POST['paintops_save_notifications_enabled'] ) && check_admin_referer( 'paintops_save_notifications_enabled_action' ) ) {
            update_option( 'paintops_notifications_enabled', ( !empty( $_POST['notifications_enabled'] ) ? '1' : '0' ) );
            echo '<div class="notice notice-success"><p>Job Notification setting saved.</p></div>';
        }
        if ( isset( $_POST['paintops_save_appearance'] ) && check_admin_referer( 'paintops_save_appearance_action' ) ) {
            // One combined handler for the merged Appearance section: theme +
            // navigation + app page display, one form and one Save button
            // (2026-07-19, Mike's request; replaced the three separate
            // paintops_save_app_theme / paintops_save_nav_style /
            // paintops_save_app_chrome handlers). Each group only updates when
            // its field is present in the POST, so a subsection that did not
            // render (function_exists guard) can never reset its options.
            if ( isset( $_POST['paintops_app_theme'] ) ) {
                $theme_input = sanitize_key( wp_unslash( $_POST['paintops_app_theme'] ) );
                if ( !function_exists( 'paintops_app_themes' ) || !isset( paintops_app_themes()[$theme_input] ) ) {
                    $theme_input = 'seaside';
                }
                update_option( 'paintops_app_theme', $theme_input );
            }
            if ( isset( $_POST['paintops_nav_fields'] ) ) {
                // The checkboxes are absent from POST when unchecked, so they
                // are keyed off this hidden marker field (always posted when
                // the Navigation subsection rendered). The old menu-style
                // radios are gone (Pills/Tabs retired 2026-07-19, unified app
                // bar).
                update_option( 'paintops_nav_sticky', ( !empty( $_POST['paintops_nav_sticky'] ) ? '1' : '0' ) );
                update_option( 'paintops_hide_admin_bar', ( !empty( $_POST['paintops_hide_admin_bar'] ) ? '1' : '0' ) );
            }
            if ( isset( $_POST['paintops_app_chrome_mode'] ) ) {
                // Two values since 2026-07-19 ('all' retired): show on mobile
                // ('off') or hide on mobile ('mobile'). Desktop always shows
                // the theme chrome.
                $chrome_in = sanitize_key( wp_unslash( $_POST['paintops_app_chrome_mode'] ) );
                if ( !in_array( $chrome_in, ['off', 'mobile'], true ) ) {
                    $chrome_in = 'off';
                }
                update_option( 'paintops_app_chrome_mode', $chrome_in );
            }
            if ( isset( $_POST['paintops_app_chrome_desktop'] ) ) {
                $chrome_desk_in = sanitize_key( wp_unslash( $_POST['paintops_app_chrome_desktop'] ) );
                if ( !in_array( $chrome_desk_in, ['show', 'hide'], true ) ) {
                    $chrome_desk_in = 'show';
                }
                update_option( 'paintops_app_chrome_desktop', $chrome_desk_in );
            }
            echo '<div class="notice notice-success"><p>Appearance settings saved.</p></div>';
        }
        if ( isset( $_POST['paintops_holiday_action'] ) && check_admin_referer( 'paintops_save_holidays_action' ) ) {
            // Start from the current effective list (this seeds the defaults into
            // the option the first time the admin adds/removes anything).
            $holidays = ( function_exists( 'paintops_calendar_holidays' ) ? paintops_calendar_holidays() : [] );
            $action = sanitize_key( (string) ($_POST['paintops_holiday_action'] ?? '') );
            if ( $action === 'add' ) {
                $label = sanitize_text_field( wp_unslash( (string) ($_POST['holiday_label'] ?? '') ) );
                $type = ( sanitize_key( (string) ($_POST['holiday_type'] ?? 'fixed') ) === 'float' ? 'float' : 'fixed' );
                if ( $label === '' ) {
                    echo '<div class="notice notice-error"><p>Enter a label for the holiday.</p></div>';
                } elseif ( $type === 'float' ) {
                    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
                    $m = (int) ($_POST['holiday_fmonth'] ?? 0);
                    $week = sanitize_key( (string) ($_POST['holiday_week'] ?? '') );
                    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- accepted only if in_array() matches a fixed weekday name below
                    $weekday = (string) ($_POST['holiday_weekday'] ?? '');
                    if ( $m >= 1 && $m <= 12 && in_array( $week, paintops_holiday_week_ordinals(), true ) && in_array( $weekday, paintops_holiday_weekday_names(), true ) ) {
                        $holidays[] = [
                            'type'    => 'float',
                            'month'   => $m,
                            'week'    => $week,
                            'weekday' => $weekday,
                            'label'   => $label,
                        ];
                        update_option( 'paintops_calendar_holidays', array_values( $holidays ) );
                        echo '<div class="notice notice-success"><p>Holiday added.</p></div>';
                    } else {
                        echo '<div class="notice notice-error"><p>Pick a week, weekday, and month for the repeating rule.</p></div>';
                    }
                } else {
                    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
                    $m = (int) ($_POST['holiday_month'] ?? 0);
                    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
                    $d = (int) ($_POST['holiday_day'] ?? 0);
                    if ( $m >= 1 && $m <= 12 && $d >= 1 ) {
                        // Clamp the day to the chosen month's length (2024 is a leap
                        // year so February allows 29), so e.g. "Feb 31" can't be saved.
                        $maxDay = (int) date( 't', mktime(
                            0,
                            0,
                            0,
                            $m,
                            1,
                            2024
                        ) );
                        if ( $d > $maxDay ) {
                            $d = $maxDay;
                        }
                        $holidays[] = [
                            'type'  => 'fixed',
                            'month' => $m,
                            'day'   => $d,
                            'label' => $label,
                        ];
                        update_option( 'paintops_calendar_holidays', array_values( $holidays ) );
                        echo '<div class="notice notice-success"><p>Holiday added.</p></div>';
                    } else {
                        echo '<div class="notice notice-error"><p>Pick a month and day.</p></div>';
                    }
                }
            } elseif ( $action === 'preset' ) {
                $key = sanitize_key( (string) ($_POST['holiday_preset'] ?? '') );
                $presets = paintops_us_holiday_presets();
                if ( isset( $presets[$key] ) ) {
                    $new = $presets[$key];
                    // Skip if a holiday with the same label is already present, so
                    // clicking a preset twice doesn't duplicate it.
                    $exists = false;
                    foreach ( $holidays as $h ) {
                        if ( strcasecmp( (string) $h['label'], (string) $new['label'] ) === 0 ) {
                            $exists = true;
                            break;
                        }
                    }
                    if ( $exists ) {
                        echo '<div class="notice notice-info"><p>' . esc_html( $new['label'] ) . ' is already in the list.</p></div>';
                    } else {
                        $holidays[] = $new;
                        update_option( 'paintops_calendar_holidays', array_values( $holidays ) );
                        echo '<div class="notice notice-success"><p>Added ' . esc_html( $new['label'] ) . '.</p></div>';
                    }
                }
            } elseif ( $action === 'remove' ) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
                $idx = (int) ($_POST['holiday_index'] ?? -1);
                if ( isset( $holidays[$idx] ) ) {
                    array_splice( $holidays, $idx, 1 );
                    update_option( 'paintops_calendar_holidays', array_values( $holidays ) );
                    echo '<div class="notice notice-success"><p>Holiday removed.</p></div>';
                }
            }
        }
        $test_email_result = null;
        if ( isset( $_POST['paintops_send_test_email'] ) && check_admin_referer( 'paintops_send_test_email_action' ) ) {
            $test_email_result = paintops_send_test_email();
        }
        // Stripe and OpenAI key saves, premium build only.
        if ( function_exists( 'paintops_handle_pro_settings_saves' ) ) {
            paintops_handle_pro_settings_saves();
        }
        $page_id = paintops_get_app_page_id();
        $app_url = esc_url( paintops_app_url() );
        $last_error = get_option( 'paintops_app_page_last_error', '' );
        echo '<div class="wrap paintops-admin-wrap">';
        echo '<h1>PaintOps Settings</h1>';
        echo '<p class="description">Click a section below to open it.</p>';
        paintops_echo_settings_accordion_styles();
        // Setup Health: one consolidated, status-driven board at the very top,
        // folding in what used to be the separate App Page, Date & Time,
        // Security (HTTPS), Offline, Google Maps, Job Notification, and Email
        // Deliverability sections. Same POST fields + nonces, so the save
        // handlers above are unchanged. (Stripe and AI keep their own sections.)
        paintops_render_setup_health_section(
            (int) $page_id,
            $app_url,
            (string) $last_error,
            $test_email_result
        );
        // Appearance: the app theme library (see includes/app-themes.php) PLUS
        // the Navigation style and App Page Display choices, folded into this
        // one section with a single Save button (2026-07-19, Mike's request;
        // they are all "how the app looks" settings). Any nav style works with
        // any theme. See paintops_render_app_nav() (manage-jobs.php) and
        // paintops_app_chrome_mode()/paintops_render_app_canvas_if_hidden().
        // Theme picker stays radio cards with color swatches rather than a bare
        // dropdown, so an admin can see roughly what they're picking.
        $app_themes = ( function_exists( 'paintops_app_themes' ) ? paintops_app_themes() : [] );
        $active_theme = ( function_exists( 'paintops_app_theme' ) ? paintops_app_theme() : 'classic' );
        $nav_sticky = ( function_exists( 'paintops_nav_sticky' ) ? paintops_nav_sticky() : false );
        $chrome_mode = paintops_app_chrome_mode();
        $appearance_meta = [];
        if ( $app_themes ) {
            $appearance_meta[] = $app_themes[$active_theme]['label'] ?? ucfirst( $active_theme );
        }
        $appearance_meta[] = 'App bar' . (( $nav_sticky ? ' (sticky)' : '' ));
        $appearance_meta[] = ( $chrome_mode === 'mobile' ? 'full screen on mobile' : 'header shown' );
        echo wp_kses( '<details class="paintops-settings-section"><summary>Appearance' . paintops_settings_summary_right( esc_html( implode( ', ', $appearance_meta ) ) ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">How the app looks for everyone on the team: color theme, menu style, and whether your site\'s header and footer wrap the app page. Customer-facing quote pages keep their own clean look and are not affected.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_appearance_action' );
        if ( $app_themes ) {
            echo '<h3 style="margin:18px 0 4px;">Theme</h3>';
            echo '<p class="description">The app\'s color scheme and styling, applies to the whole PaintOps app (nav, cards, forms, Job Board).</p>';
            echo '<div style="display:flex;flex-wrap:wrap;gap:12px;margin:12px 0;">';
            foreach ( $app_themes as $theme_key => $theme_def ) {
                $vars = (array) ($theme_def['vars'] ?? []);
                // Swatch strip: nav bar, accent, card surface, page/tile
                // surface, enough to convey the personality at a glance.
                // Classic's accent var references --nv-primary-accent, which
                // has no value in wp-admin, so show its fallback color.
                $swatches = [
                    $vars['--po-nav-bg'] ?? '#4682B4',
                    $vars['--po-accent'] ?? '#4682B4',
                    $vars['--po-card-bg'] ?? '#fff',
                    $vars['--po-tile-bg'] ?? '#fafbfc'
                ];
                foreach ( $swatches as $i => $sw ) {
                    if ( strpos( $sw, 'var(' ) === 0 ) {
                        $swatches[$i] = '#4682B4';
                    }
                }
                $checked = ( $theme_key === $active_theme ? ' checked' : '' );
                $border = ( $theme_key === $active_theme ? '2px solid #4682B4' : '1px solid #ccd0d4' );
                echo wp_kses( '<label style="border:' . esc_attr( $border ) . ';border-radius:8px;padding:12px 14px;cursor:pointer;min-width:220px;max-width:280px;background:#fff;">', paintops_kses_allowed_html(), paintops_kses_protocols() );
                echo wp_kses( '<input type="radio" name="paintops_app_theme" value="' . esc_attr( $theme_key ) . '"' . $checked . ' style="margin-right:6px;">', paintops_kses_allowed_html(), paintops_kses_protocols() );
                echo '<strong>' . esc_html( $theme_def['label'] ?? ucfirst( $theme_key ) ) . '</strong>';
                echo '<span style="display:flex;gap:4px;margin:8px 0 6px;">';
                foreach ( $swatches as $sw ) {
                    echo '<span style="display:inline-block;width:34px;height:18px;border-radius:3px;border:1px solid rgba(0,0,0,.15);background:' . esc_attr( $sw ) . ';"></span>';
                }
                echo '</span>';
                echo '<span class="description" style="display:block;font-size:12px;">' . esc_html( $theme_def['description'] ?? '' ) . '</span>';
                echo '</label>';
            }
            echo '</div>';
        }
        // Navigation subsection (same form). The Pills/Tabs menu-style radios
        // are gone (retired 2026-07-19): the app now has ONE navigation, an
        // app-style bar, fixed to the bottom on phones (Jobs, a raised center
        // New Request, and More) and shown as a matching bar at the top on
        // larger screens. Only the sticky preference remains.
        if ( function_exists( 'paintops_nav_sticky' ) ) {
            echo '<h3 style="margin:22px 0 4px;">Navigation</h3>';
            echo '<p class="description">The app uses one unified menu bar: fixed to the bottom on phones (Jobs, New Request, and More), and the same style across the top on larger screens.</p>';
            echo '<input type="hidden" name="paintops_nav_fields" value="1">';
            echo '<table class="form-table" role="presentation">';
            echo '<tr><th scope="row">Sticky</th><td>';
            echo '<p><label><input type="checkbox" name="paintops_nav_sticky" value="1"' . checked( $nav_sticky, true, false ) . '> Keep the bar pinned to the top while scrolling on large screens. (Phones are unaffected, the bottom bar is always in reach.)</label></p>';
            echo '</td></tr>';
            echo '<tr><th scope="row">WordPress admin bar</th><td>';
            echo '<p><label><input type="checkbox" name="paintops_hide_admin_bar" value="1"' . checked( ( function_exists( 'paintops_hide_admin_bar_on_app' ) ? paintops_hide_admin_bar_on_app() : true ), true, false ) . '> Hide the WordPress admin bar on the app page wherever the site header &amp; footer are hidden (see App Page Display below). The rest of your site and wp-admin keep it.</label></p>';
            echo '</td></tr>';
            echo '</table>';
        }
        // App Page Display subsection (same form). Split per device class
        // (2026-07-22, Mike's request): mobile and desktop each get their own
        // choice; new installs default to hide-on-mobile + show-on-desktop.
        $chrome_desktop = ( function_exists( 'paintops_app_chrome_desktop_mode' ) ? paintops_app_chrome_desktop_mode() : 'show' );
        echo '<h3 style="margin:22px 0 4px;">App Page Display</h3>';
        echo '<p class="description">Your theme\'s header and footer can be hidden on the PaintOps app page so the app gets the whole screen. Phones and desktops are set separately; this only affects the app page, not the rest of your site.</p>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row">Site header &amp; footer on mobile</th><td>';
        echo '<p><label><input type="radio" name="paintops_app_chrome_mode" value="mobile"' . checked( $chrome_mode, 'mobile', false ) . '> <strong>Hide on mobile</strong> (recommended) - phones get the full-screen app.</label></p>';
        echo '<p><label><input type="radio" name="paintops_app_chrome_mode" value="off"' . checked( $chrome_mode, 'off', false ) . '> <strong>Show on mobile</strong> - phones see the theme header and footer.</label></p>';
        echo '</td></tr>';
        echo '<tr><th scope="row">Site header &amp; footer on desktop</th><td>';
        echo '<p><label><input type="radio" name="paintops_app_chrome_desktop" value="show"' . checked( $chrome_desktop, 'show', false ) . '> <strong>Show on desktop</strong> (recommended) - the app sits inside your normal site layout.</label></p>';
        echo '<p><label><input type="radio" name="paintops_app_chrome_desktop" value="hide"' . checked( $chrome_desktop, 'hide', false ) . '> <strong>Hide on desktop</strong> - desktops also get the full-screen app canvas.</label></p>';
        echo '</td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_appearance" value="1" class="button button-primary">Save Appearance</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Calendar Holidays, display-only labels shown on their date in the
        // Calendar (paintops_calendar_holidays() + includes/calendar.php). Notes
        // only, not time off/scheduling. Each recurs every year; fixed dates or
        // floating "Nth weekday of month" rules (e.g. Thanksgiving).
        if ( function_exists( 'paintops_calendar_holidays' ) ) {
            $holidays = paintops_calendar_holidays();
            $holYear = (int) gmdate( 'Y', current_time( 'timestamp' ) );
            echo wp_kses( '<details class="paintops-settings-section"><summary>Calendar Holidays' . paintops_settings_summary_right( esc_html( (string) count( $holidays ) ) . ' shown' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo '<p class="description">Labels shown on their date in the Calendar - display-only notes, not time off or scheduling. Each repeats every year. Use a fixed date (e.g. Dec 25), or a repeating weekday for holidays that move, like Thanksgiving (4th Thursday of November).</p>';
            if ( $holidays ) {
                echo '<table class="form-table" role="presentation"><tbody>';
                foreach ( $holidays as $i => $h ) {
                    $when = esc_html( paintops_calendar_holiday_when_label( $h ) );
                    $yearNote = '';
                    if ( ($h['type'] ?? 'fixed') === 'float' ) {
                        $rd = paintops_calendar_holiday_day( $h, $holYear );
                        if ( $rd !== null ) {
                            $yearNote = ' <span style="color:#787c82;">(' . esc_html( date( 'M j, Y', mktime(
                                0,
                                0,
                                0,
                                (int) $h['month'],
                                $rd,
                                $holYear
                            ) ) ) . ')</span>';
                        }
                    }
                    echo '<tr><th scope="row" style="font-weight:400;">' . esc_html( $h['label'] ) . '</th><td>';
                    echo wp_kses( '<span style="display:inline-block;min-width:190px;color:#50575e;">' . $when . $yearNote . '</span> ', paintops_kses_allowed_html(), paintops_kses_protocols() );
                    echo '<form method="post" style="display:inline;">';
                    wp_nonce_field( 'paintops_save_holidays_action' );
                    echo '<input type="hidden" name="paintops_holiday_action" value="remove">';
                    echo '<input type="hidden" name="holiday_index" value="' . esc_attr( (string) $i ) . '">';
                    echo '<button type="submit" class="button-link" style="color:#b32d2e;">Remove</button>';
                    echo '</form></td></tr>';
                }
                echo '</tbody></table>';
            } else {
                echo '<p><em>No holidays yet - add one below.</em></p>';
            }
            // One-click US presets (Thanksgiving, Memorial Day, etc.).
            echo '<h4 style="margin:16px 0 6px;">Quick add (US holidays)</h4>';
            echo '<form method="post" style="margin-bottom:6px;">';
            wp_nonce_field( 'paintops_save_holidays_action' );
            echo '<input type="hidden" name="paintops_holiday_action" value="preset">';
            echo '<div style="display:flex;flex-wrap:wrap;gap:6px;">';
            foreach ( paintops_us_holiday_presets() as $pkey => $pdef ) {
                echo '<button type="submit" name="holiday_preset" value="' . esc_attr( $pkey ) . '" class="button">' . esc_html( $pdef['label'] ) . '</button>';
            }
            echo '</div>';
            echo '</form>';
            // Add your own, fixed date OR a floating "Nth weekday of month" rule.
            echo '<h4 style="margin:16px 0 6px;">Add your own</h4>';
            echo '<form method="post" id="paintops-add-holiday">';
            wp_nonce_field( 'paintops_save_holidays_action' );
            echo '<input type="hidden" name="paintops_holiday_action" value="add">';
            echo '<table class="form-table" role="presentation">';
            echo '<tr><th scope="row"><label for="holiday_label">Label</label></th><td><input type="text" id="holiday_label" name="holiday_label" class="regular-text" placeholder="e.g. Independence Day" required></td></tr>';
            echo '<tr><th scope="row">When</th><td>';
            echo '<p style="margin-top:0;"><label><input type="radio" name="holiday_type" value="fixed" checked> Fixed date (same day every year)</label> &nbsp; ';
            echo '<label><input type="radio" name="holiday_type" value="float"> Repeating weekday (moves each year)</label></p>';
            echo '</td></tr>';
            echo '<tr class="paintops-hol-fixed"><th scope="row">Date</th><td>';
            echo '<select name="holiday_month" aria-label="Month">';
            for ($mm = 1; $mm <= 12; $mm++) {
                echo wp_kses( '<option value="' . (int) $mm . '">' . esc_html( date( 'F', mktime(
                    0,
                    0,
                    0,
                    $mm,
                    1,
                    2024
                ) ) ) . '</option>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            }
            echo '</select> ';
            echo '<input type="number" name="holiday_day" min="1" max="31" step="1" placeholder="Day" style="width:80px;" aria-label="Day of month">';
            echo '</td></tr>';
            echo '<tr class="paintops-hol-float" style="display:none;"><th scope="row">Rule</th><td>';
            echo '<select name="holiday_week" aria-label="Week">';
            foreach ( paintops_holiday_week_ordinals() as $ord ) {
                echo '<option value="' . esc_attr( $ord ) . '">' . esc_html( ucfirst( $ord ) ) . '</option>';
            }
            echo '</select> ';
            echo '<select name="holiday_weekday" aria-label="Weekday">';
            foreach ( paintops_holiday_weekday_names() as $wd ) {
                echo '<option value="' . esc_attr( $wd ) . '">' . esc_html( $wd ) . '</option>';
            }
            echo '</select> of ';
            echo '<select name="holiday_fmonth" aria-label="Month">';
            for ($mm = 1; $mm <= 12; $mm++) {
                echo wp_kses( '<option value="' . (int) $mm . '"' . (( $mm === 11 ? ' selected' : '' )) . '>' . esc_html( date( 'F', mktime(
                    0,
                    0,
                    0,
                    $mm,
                    1,
                    2024
                ) ) ) . '</option>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            }
            echo '</select> <span class="description">e.g. Fourth Thursday of November</span>';
            echo '</td></tr>';
            echo '</table>';
            echo '<p><button type="submit" class="button button-primary">Add Holiday</button></p>';
            echo '</form>';
            // Progressive enhancement: show only the field row matching the chosen
            // type. Both rows work without JS (the handler reads holiday_type).
            paintops_enqueue_script( 'settings-holiday-form' );
            echo '</div></details>';
        }
        // Email Deliverability now lives in the Setup Health board at the top of
        // this page (folded in with its Send Test Email control; the test result
        // is passed to the board via $test_email_result).
        // Stripe settings section, premium build only.
        if ( function_exists( 'paintops_render_stripe_settings_section' ) ) {
            paintops_render_stripe_settings_section();
        }
        // AI settings section, premium build only.
        if ( function_exists( 'paintops_render_ai_settings_section' ) ) {
            paintops_render_ai_settings_section();
        }
        // Google Reviews settings section, premium build only.
        if ( function_exists( 'paintops_render_review_settings_section' ) ) {
            paintops_render_review_settings_section();
        }
        echo '</div>';
    }

    /**
     * Render the Business settings page: company profile, job categories,
     * and pricing rules. This is what makes the plugin usable for any
     * painting business rather than one specific company.
     */
    function paintops_render_business_settings_page() : void {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to manage PaintOps business settings.', 'paintops' ) );
        }
        // --- Save: company profile ---
        if ( isset( $_POST['paintops_save_company_profile'] ) && check_admin_referer( 'paintops_save_company_profile_action' ) ) {
            // Sanitized dropdown reads, validated against the fixed option
            // lists in the profile array below (unknown values fall back).
            $po_country_in = sanitize_text_field( wp_unslash( (string) ($_POST['company_country'] ?? '') ) );
            $po_currency_in = strtoupper( sanitize_text_field( wp_unslash( (string) ($_POST['company_currency'] ?? '') ) ) );
            $po_units_in = sanitize_text_field( wp_unslash( (string) ($_POST['company_units'] ?? '') ) );
            $profile = [
                'name'     => sanitize_text_field( wp_unslash( $_POST['company_name'] ?? '' ) ),
                'address1' => sanitize_text_field( wp_unslash( $_POST['company_address1'] ?? '' ) ),
                'address2' => sanitize_text_field( wp_unslash( $_POST['company_address2'] ?? '' ) ),
                'phone'    => sanitize_text_field( wp_unslash( $_POST['company_phone'] ?? '' ) ),
                'email'    => sanitize_email( wp_unslash( $_POST['company_email'] ?? '' ) ),
                'website'  => esc_url_raw( wp_unslash( $_POST['company_website'] ?? '' ) ),
                'logo'     => esc_url_raw( wp_unslash( $_POST['company_logo'] ?? '' ) ),
                'country'  => ( array_key_exists( $po_country_in, paintops_address_format_options() ) ? $po_country_in : 'US' ),
                'currency' => ( array_key_exists( $po_currency_in, paintops_supported_currencies() ) ? $po_currency_in : 'USD' ),
                'units'    => ( array_key_exists( $po_units_in, paintops_measurement_units_options() ) ? $po_units_in : 'imperial' ),
            ];
            update_option( 'paintops_company_profile', $profile );
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
            update_option( 'paintops_job_photo_limit', max( 1, (int) ($_POST['job_photo_limit'] ?? 15) ) );
            // Quote T&C (v1.0.105): plain text with line breaks, kses'd on
            // save AND on output. Saving an empty box is a deliberate "show
            // no terms" (stores '', distinct from never-saved null).
            update_option( 'paintops_quote_terms', wp_kses_post( wp_unslash( $_POST['paintops_quote_terms'] ?? '' ) ) );
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
            update_option( 'paintops_quote_valid_days', max( 0, (int) ($_POST['paintops_quote_valid_days'] ?? 30) ) );
            echo '<div class="notice notice-success"><p>Company profile saved.</p></div>';
        }
        // Vehicle & Mileage save, premium build only.
        if ( function_exists( 'paintops_handle_vehicle_settings_save' ) ) {
            paintops_handle_vehicle_settings_save();
        }
        // --- Save: Quote & Invoice branding credit, opt-in on every tier ---
        if ( isset( $_POST['paintops_save_branding'] ) && check_admin_referer( 'paintops_save_branding_action' ) ) {
            update_option( 'paintops_show_powered_by', ( !empty( $_POST['paintops_show_powered_by'] ) ? '1' : '0' ) );
            echo '<div class="notice notice-success"><p>Quote &amp; invoice branding saved.</p></div>';
        }
        // --- Save: pricing rules ---
        if ( isset( $_POST['paintops_save_pricing'] ) && check_admin_referer( 'paintops_save_pricing_action' ) ) {
            $rules = [
                'labor_rate_per_day'     => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['labor_rate_per_day'] ?? 0) ) ) ),
                'markup_percent'         => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['markup_percent'] ?? 0) ) ) ),
                'deposit_high_threshold' => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['deposit_high_threshold'] ?? 0) ) ) ),
                'deposit_high_percent'   => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['deposit_high_percent'] ?? 0) ) ) ),
                'deposit_mid_threshold'  => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['deposit_mid_threshold'] ?? 0) ) ) ),
                'deposit_mid_percent'    => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['deposit_mid_percent'] ?? 0) ) ) ),
                'card_fee_percent'       => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['card_fee_percent'] ?? 0) ) ) ),
                'tax_label'              => ( sanitize_text_field( wp_unslash( (string) ($_POST['tax_label'] ?? 'Sales Tax') ) ) ?: 'Sales Tax' ),
                'tax_percent'            => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['tax_percent'] ?? 0) ) ) ),
            ];
            update_option( 'paintops_pricing_rules', $rules );
            echo '<div class="notice notice-success"><p>Pricing rules saved.</p></div>';
        }
        // --- Save: job categories ---
        if ( isset( $_POST['paintops_save_categories'] ) && check_admin_referer( 'paintops_save_categories_action' ) ) {
            $categories = [];
            $ids = map_deep( wp_unslash( $_POST['cat_id'] ?? [] ), 'sanitize_textarea_field' );
            $names = map_deep( wp_unslash( $_POST['cat_name'] ?? [] ), 'sanitize_textarea_field' );
            $standards = map_deep( wp_unslash( $_POST['cat_standards'] ?? [] ), 'sanitize_textarea_field' );
            // Calculators is a checkbox list built from
            // paintops_calculator_options() (includes/trade-calculators.php)
            //, a category can have more than one checked at once (e.g. an
            // interior residential category offering Paint, Cabinet & Trim,
            // and Labor Days together). $cat_calculators[$i] arrives as its
            // own array of checked keys (or missing entirely if none are
            // checked), validated against the registry below rather than
            // trusted as free text, same reasoning as any other checkbox/
            // select: nothing stops a crafted POST from sending a key that
            // was never actually in the list.
            $calculatorKeysByRow = map_deep( wp_unslash( $_POST['cat_calculators'] ?? [] ), 'sanitize_textarea_field' );
            $validCalculatorKeys = array_filter( ( function_exists( 'paintops_calculator_options' ) ? array_keys( paintops_calculator_options() ) : [] ), static function ( string $key ) : bool {
                return $key !== '';
                // '' is the "None" placeholder, not a real selectable calculator
            } );
            $supplyHint1 = map_deep( wp_unslash( $_POST['cat_supply_hint_1'] ?? [] ), 'sanitize_textarea_field' );
            $supplyHint2 = map_deep( wp_unslash( $_POST['cat_supply_hint_2'] ?? [] ), 'sanitize_textarea_field' );
            $supplyHint3 = map_deep( wp_unslash( $_POST['cat_supply_hint_3'] ?? [] ), 'sanitize_textarea_field' );
            $supplyHint4 = map_deep( wp_unslash( $_POST['cat_supply_hint_4'] ?? [] ), 'sanitize_textarea_field' );
            $supplyHint5 = map_deep( wp_unslash( $_POST['cat_supply_hint_5'] ?? [] ), 'sanitize_textarea_field' );
            $deletes = map_deep( wp_unslash( $_POST['cat_delete'] ?? [] ), 'sanitize_textarea_field' );
            // Scope Components: a per-category checklist, so these arrive
            // nested one level deeper than the fields above, keyed first by
            // the category's row index ($i, same as everything else here),
            // then by the component's own stable id (same reasoning as
            // sit_delete[]/term_delete[] elsewhere in this file: a delete
            // checkbox or edit must target the actual row, not whatever
            // position it happens to render in after an earlier row was
            // deleted).
            $compIds = map_deep( wp_unslash( $_POST['cat_comp_id'] ?? [] ), 'sanitize_textarea_field' );
            $compLabels = map_deep( wp_unslash( $_POST['cat_comp_label'] ?? [] ), 'sanitize_textarea_field' );
            $compTexts = map_deep( wp_unslash( $_POST['cat_comp_text'] ?? [] ), 'sanitize_textarea_field' );
            $compAppliesTo = map_deep( wp_unslash( $_POST['cat_comp_applies_to'] ?? [] ), 'sanitize_textarea_field' );
            $compDeletes = map_deep( wp_unslash( $_POST['cat_comp_delete'] ?? [] ), 'sanitize_textarea_field' );
            $newCompLabels = map_deep( wp_unslash( $_POST['new_cat_comp_label'] ?? [] ), 'sanitize_textarea_field' );
            $newCompTexts = map_deep( wp_unslash( $_POST['new_cat_comp_text'] ?? [] ), 'sanitize_textarea_field' );
            $newCompAppliesTo = map_deep( wp_unslash( $_POST['new_cat_comp_applies_to'] ?? [] ), 'sanitize_textarea_field' );
            $valid_applies_to = ['interior', 'exterior', 'both'];
            foreach ( $ids as $i => $id ) {
                $id = (int) $id;
                if ( $id <= 0 || !empty( $deletes[$i] ) ) {
                    continue;
                    // skip deleted rows
                }
                $name = sanitize_text_field( $names[$i] ?? '' );
                if ( $name === '' ) {
                    continue;
                    // skip rows with no name
                }
                $components = [];
                $rowCompIds = (array) ($compIds[$i] ?? []);
                $rowCompLabels = (array) ($compLabels[$i] ?? []);
                $rowCompTexts = (array) ($compTexts[$i] ?? []);
                $rowCompAppliesTo = (array) ($compAppliesTo[$i] ?? []);
                $rowCompDeletes = (array) ($compDeletes[$i] ?? []);
                foreach ( $rowCompIds as $ci => $comp_id ) {
                    $comp_id = (int) $comp_id;
                    if ( $comp_id <= 0 || !empty( $rowCompDeletes[$comp_id] ) ) {
                        continue;
                    }
                    $comp_label = sanitize_text_field( wp_unslash( $rowCompLabels[$ci] ?? '' ) );
                    if ( $comp_label === '' ) {
                        continue;
                    }
                    $comp_applies_to = sanitize_key( wp_unslash( $rowCompAppliesTo[$ci] ?? 'both' ) );
                    if ( !in_array( $comp_applies_to, $valid_applies_to, true ) ) {
                        $comp_applies_to = 'both';
                    }
                    $components[$comp_id] = [
                        'label'      => $comp_label,
                        'text'       => sanitize_textarea_field( wp_unslash( $rowCompTexts[$ci] ?? '' ) ),
                        'applies_to' => $comp_applies_to,
                    ];
                }
                $new_comp_label = sanitize_text_field( $newCompLabels[$i] ?? '' );
                if ( $new_comp_label !== '' ) {
                    $next_comp_id = ( empty( $components ) ? 1 : max( array_keys( $components ) ) + 1 );
                    $new_comp_applies_to = sanitize_key( $newCompAppliesTo[$i] ?? 'both' );
                    if ( !in_array( $new_comp_applies_to, $valid_applies_to, true ) ) {
                        $new_comp_applies_to = 'both';
                    }
                    $components[$next_comp_id] = [
                        'label'      => $new_comp_label,
                        'text'       => sanitize_textarea_field( $newCompTexts[$i] ?? '' ),
                        'applies_to' => $new_comp_applies_to,
                    ];
                }
                $row_calculators = array_map( static function ( $key ) : string {
                    return sanitize_key( wp_unslash( (string) $key ) );
                }, (array) ($calculatorKeysByRow[$i] ?? []) );
                $row_calculators = array_values( array_intersect( $row_calculators, $validCalculatorKeys ) );
                $categories[$id] = [
                    'name'             => $name,
                    'standards'        => sanitize_textarea_field( $standards[$i] ?? '' ),
                    'calculators'      => $row_calculators,
                    'resource_title'   => '',
                    'resource_links'   => '',
                    'supply_hints'     => [
                        sanitize_text_field( $supplyHint1[$i] ?? '' ),
                        sanitize_text_field( $supplyHint2[$i] ?? '' ),
                        sanitize_text_field( $supplyHint3[$i] ?? '' ),
                        sanitize_text_field( $supplyHint4[$i] ?? '' ),
                        sanitize_text_field( $supplyHint5[$i] ?? '' )
                    ],
                    'scope_components' => $components,
                ];
            }
            // Handle a new category, if one was filled in.
            $new_name = sanitize_text_field( wp_unslash( $_POST['new_cat_name'] ?? '' ) );
            if ( $new_name !== '' ) {
                $next_id = ( empty( $categories ) ? 1 : max( array_keys( $categories ) ) + 1 );
                $categories[$next_id] = paintops_normalize_job_category( [
                    'name'      => $new_name,
                    'standards' => sanitize_textarea_field( wp_unslash( $_POST['new_cat_standards'] ?? '' ) ),
                ] );
            }
            if ( !empty( $categories ) ) {
                update_option( 'paintops_job_categories', $categories );
                echo '<div class="notice notice-success"><p>Job categories saved.</p></div>';
            } else {
                echo '<div class="notice notice-error"><p>You need at least one job category - nothing was saved.</p></div>';
            }
        }
        // --- Save: job classifications ---
        if ( isset( $_POST['paintops_save_job_classifications'] ) && check_admin_referer( 'paintops_save_job_classifications_action' ) ) {
            $classifications = [];
            $classIds = map_deep( wp_unslash( $_POST['class_id'] ?? [] ), 'sanitize_textarea_field' );
            $classLabels = map_deep( wp_unslash( $_POST['class_label'] ?? [] ), 'sanitize_textarea_field' );
            $classDeletes = map_deep( wp_unslash( $_POST['class_delete'] ?? [] ), 'sanitize_textarea_field' );
            foreach ( $classIds as $i => $id ) {
                $id = (int) $id;
                // class_delete[] is keyed by the classification's actual id,
                // not loop position, same reasoning as term_delete[] etc.
                if ( $id <= 0 || !empty( $classDeletes[$id] ) ) {
                    continue;
                }
                $label = sanitize_text_field( $classLabels[$i] ?? '' );
                if ( $label === '' ) {
                    continue;
                }
                $classifications[$id] = $label;
            }
            $new_label = sanitize_text_field( wp_unslash( $_POST['new_class_label'] ?? '' ) );
            if ( $new_label !== '' ) {
                $next_id = ( empty( $classifications ) ? 1 : max( array_keys( $classifications ) ) + 1 );
                $classifications[$next_id] = $new_label;
            }
            if ( !empty( $classifications ) ) {
                update_option( 'paintops_job_classifications', $classifications );
                echo '<div class="notice notice-success"><p>Job classifications saved.</p></div>';
            } else {
                echo '<div class="notice notice-error"><p>You need at least one job classification - nothing was saved.</p></div>';
            }
        }
        // --- Save: scope terms (scanned-word library) ---
        if ( isset( $_POST['paintops_save_scope_terms'] ) && check_admin_referer( 'paintops_save_scope_terms_action' ) ) {
            $terms = [];
            $termIds = map_deep( wp_unslash( $_POST['term_id'] ?? [] ), 'sanitize_textarea_field' );
            $termWords = map_deep( wp_unslash( $_POST['term_word'] ?? [] ), 'sanitize_textarea_field' );
            $termDisclaimers = map_deep( wp_unslash( $_POST['term_disclaimer'] ?? [] ), 'sanitize_textarea_field' );
            $termDeletes = map_deep( wp_unslash( $_POST['term_delete'] ?? [] ), 'sanitize_textarea_field' );
            foreach ( $termIds as $i => $id ) {
                $id = (int) $id;
                // term_delete[]/term_disclaimer[] checkboxes are rendered
                // keyed by the term's actual id (see the admin UI below),
                // not by loop position, so this must look them up by $id,
                // not $i, or deletes/edits silently apply to the wrong row
                // whenever term ids aren't a plain 0,1,2... sequence.
                if ( $id <= 0 || !empty( $termDeletes[$id] ) ) {
                    continue;
                }
                $word = sanitize_text_field( $termWords[$i] ?? '' );
                if ( $word === '' ) {
                    continue;
                }
                $terms[$id] = [
                    'term'       => $word,
                    'disclaimer' => sanitize_textarea_field( $termDisclaimers[$i] ?? '' ),
                ];
            }
            $new_term = sanitize_text_field( wp_unslash( $_POST['new_term_word'] ?? '' ) );
            if ( $new_term !== '' ) {
                $next_id = ( empty( $terms ) ? 1 : max( array_keys( $terms ) ) + 1 );
                $terms[$next_id] = [
                    'term'       => $new_term,
                    'disclaimer' => sanitize_textarea_field( wp_unslash( $_POST['new_term_disclaimer'] ?? '' ) ),
                ];
            }
            update_option( 'paintops_scope_terms', $terms );
            echo '<div class="notice notice-success"><p>Scope terms saved.</p></div>';
        }
        // --- Save: situational clauses (always-available checklist) ---
        if ( isset( $_POST['paintops_save_scope_situational'] ) && check_admin_referer( 'paintops_save_scope_situational_action' ) ) {
            $clauses = [];
            $clauseIds = map_deep( wp_unslash( $_POST['sit_id'] ?? [] ), 'sanitize_textarea_field' );
            $clauseLabels = map_deep( wp_unslash( $_POST['sit_label'] ?? [] ), 'sanitize_textarea_field' );
            $clauseDisclaimers = map_deep( wp_unslash( $_POST['sit_disclaimer'] ?? [] ), 'sanitize_textarea_field' );
            $clauseDefaults = map_deep( wp_unslash( $_POST['sit_default'] ?? [] ), 'sanitize_textarea_field' );
            $clauseDeletes = map_deep( wp_unslash( $_POST['sit_delete'] ?? [] ), 'sanitize_textarea_field' );
            foreach ( $clauseIds as $i => $id ) {
                $id = (int) $id;
                // sit_delete[]/sit_default[] checkboxes are keyed by the
                // clause's actual id (see the admin UI below), not by loop
                // position, same reasoning as term_delete[] above.
                if ( $id <= 0 || !empty( $clauseDeletes[$id] ) ) {
                    continue;
                }
                $label = sanitize_text_field( $clauseLabels[$i] ?? '' );
                if ( $label === '' ) {
                    continue;
                }
                $clauses[$id] = [
                    'label'      => $label,
                    'disclaimer' => sanitize_textarea_field( $clauseDisclaimers[$i] ?? '' ),
                    'default_on' => !empty( $clauseDefaults[$id] ),
                ];
            }
            $new_label = sanitize_text_field( wp_unslash( $_POST['new_sit_label'] ?? '' ) );
            if ( $new_label !== '' ) {
                $next_id = ( empty( $clauses ) ? 1 : max( array_keys( $clauses ) ) + 1 );
                $clauses[$next_id] = [
                    'label'      => $new_label,
                    'disclaimer' => sanitize_textarea_field( wp_unslash( $_POST['new_sit_disclaimer'] ?? '' ) ),
                    'default_on' => !empty( $_POST['new_sit_default'] ),
                ];
            }
            update_option( 'paintops_scope_situational', $clauses );
            echo '<div class="notice notice-success"><p>Situational clauses saved.</p></div>';
        }
        // --- Save: price list (reference sheet) ---
        if ( isset( $_POST['paintops_save_price_list'] ) && check_admin_referer( 'paintops_save_price_list_action' ) ) {
            $valid_units = array_keys( paintops_price_list_units() );
            $priceItems = [];
            $priceIds = map_deep( wp_unslash( $_POST['price_id'] ?? [] ), 'sanitize_textarea_field' );
            $priceLabels = map_deep( wp_unslash( $_POST['price_label'] ?? [] ), 'sanitize_textarea_field' );
            $priceUnits = map_deep( wp_unslash( $_POST['price_unit'] ?? [] ), 'sanitize_textarea_field' );
            $priceAmounts = map_deep( wp_unslash( $_POST['price_amount'] ?? [] ), 'sanitize_textarea_field' );
            $priceNotes = map_deep( wp_unslash( $_POST['price_note'] ?? [] ), 'sanitize_textarea_field' );
            $priceDeletes = map_deep( wp_unslash( $_POST['price_delete'] ?? [] ), 'sanitize_textarea_field' );
            foreach ( $priceIds as $i => $id ) {
                $id = (int) $id;
                // price_delete[] is keyed by the item's actual id, not loop
                // position, same reasoning as term_delete[]/sit_delete[].
                if ( $id <= 0 || !empty( $priceDeletes[$id] ) ) {
                    continue;
                }
                $label = sanitize_text_field( $priceLabels[$i] ?? '' );
                if ( $label === '' ) {
                    continue;
                }
                $unit = sanitize_key( $priceUnits[$i] ?? 'flat' );
                if ( !in_array( $unit, $valid_units, true ) ) {
                    $unit = 'flat';
                }
                $priceItems[$id] = [
                    'label' => $label,
                    'unit'  => $unit,
                    'price' => max( 0, (float) ($priceAmounts[$i] ?? 0) ),
                    'note'  => sanitize_text_field( $priceNotes[$i] ?? '' ),
                ];
            }
            $new_label = sanitize_text_field( wp_unslash( $_POST['new_price_label'] ?? '' ) );
            if ( $new_label !== '' ) {
                $new_unit = sanitize_key( wp_unslash( $_POST['new_price_unit'] ?? 'flat' ) );
                if ( !in_array( $new_unit, $valid_units, true ) ) {
                    $new_unit = 'flat';
                }
                $next_id = ( empty( $priceItems ) ? 1 : max( array_keys( $priceItems ) ) + 1 );
                $priceItems[$next_id] = [
                    'label' => $new_label,
                    'unit'  => $new_unit,
                    'price' => max( 0, (float) sanitize_text_field( wp_unslash( (string) ($_POST['new_price_amount'] ?? 0) ) ) ),
                    'note'  => sanitize_text_field( wp_unslash( $_POST['new_price_note'] ?? '' ) ),
                ];
            }
            update_option( 'paintops_price_list', $priceItems );
            echo '<div class="notice notice-success"><p>Price list saved.</p></div>';
        }
        // --- Save: reusable documents (labels + deletes) ---
        if ( isset( $_POST['paintops_save_documents'] ) && check_admin_referer( 'paintops_save_documents_action' ) ) {
            $documents = paintops_documents_library();
            $docIds = map_deep( wp_unslash( $_POST['doc_id'] ?? [] ), 'sanitize_textarea_field' );
            $docLabels = map_deep( wp_unslash( $_POST['doc_label'] ?? [] ), 'sanitize_textarea_field' );
            $docDeletes = map_deep( wp_unslash( $_POST['doc_delete'] ?? [] ), 'sanitize_textarea_field' );
            foreach ( $docIds as $i => $id ) {
                $id = (int) $id;
                if ( !isset( $documents[$id] ) ) {
                    continue;
                }
                if ( !empty( $docDeletes[$id] ) ) {
                    // Remove the underlying file along with the library entry,
                    // otherwise deleted documents just pile up, unused, in the
                    // uploads folder forever.
                    if ( !empty( $documents[$id]['file'] ) && file_exists( $documents[$id]['file'] ) ) {
                        wp_delete_file( $documents[$id]['file'] );
                    }
                    unset($documents[$id]);
                    continue;
                }
                $label = sanitize_text_field( $docLabels[$i] ?? '' );
                if ( $label !== '' ) {
                    $documents[$id]['label'] = $label;
                }
            }
            update_option( 'paintops_documents', $documents );
            echo '<div class="notice notice-success"><p>Documents saved.</p></div>';
        }
        // --- Upload a new reusable document ---
        // Its own standalone multipart form (see the admin UI below),
        // browsers don't support nested <form> elements, and the main
        // Documents form above doesn't need enctype="multipart/form-data"
        // since it's just editing labels/deletes on already-uploaded files.
        if ( isset( $_POST['paintops_upload_document'] ) && check_admin_referer( 'paintops_upload_document_action' ) ) {
            $new_doc_label = sanitize_text_field( wp_unslash( $_POST['new_doc_label'] ?? '' ) );
            if ( $new_doc_label === '' ) {
                echo '<div class="notice notice-error"><p>Enter a label for the document before uploading.</p></div>';
            } elseif ( empty( $_FILES['new_doc_file']['tmp_name'] ) ) {
                echo '<div class="notice notice-error"><p>Choose a file to upload.</p></div>';
            } else {
                if ( !function_exists( 'wp_handle_upload' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/file.php';
                }
                // Explicit allow-list scoped to "documents", without this,
                // wp_handle_upload() falls back to the site's full global
                // upload allow-list, which is wider than this feature intends.
                $document_mimes = [
                    'pdf'          => 'application/pdf',
                    'doc'          => 'application/msword',
                    'docx'         => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                    'jpg|jpeg|jpe' => 'image/jpeg',
                    'png'          => 'image/png',
                ];
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- the whole FILES entry is sanitized field-by-field by paintops_sanitize_upload() before wp_handle_upload() validates it
                $upload = wp_handle_upload( paintops_sanitize_upload( $_FILES['new_doc_file'] ), [
                    'test_form' => false,
                    'mimes'     => $document_mimes,
                ] );
                if ( isset( $upload['error'] ) ) {
                    echo '<div class="notice notice-error"><p>Upload failed: ' . esc_html( $upload['error'] ) . '</p></div>';
                } else {
                    $documents = paintops_documents_library();
                    $next_id = ( empty( $documents ) ? 1 : max( array_keys( $documents ) ) + 1 );
                    $documents[$next_id] = [
                        'label'       => $new_doc_label,
                        'file'        => $upload['file'],
                        'url'         => $upload['url'],
                        'filename'    => basename( $upload['file'] ),
                        'uploaded_at' => current_time( 'mysql' ),
                    ];
                    update_option( 'paintops_documents', $documents );
                    echo '<div class="notice notice-success"><p>&ldquo;' . esc_html( $new_doc_label ) . '&rdquo; uploaded.</p></div>';
                }
            }
        }
        // --- Save: follow-up email/SMS template ---
        if ( isset( $_POST['paintops_save_followup_template'] ) && check_admin_referer( 'paintops_save_followup_template_action' ) ) {
            $template = [
                'email_subject' => sanitize_text_field( wp_unslash( $_POST['followup_email_subject'] ?? '' ) ),
                'email_body'    => sanitize_textarea_field( wp_unslash( $_POST['followup_email_body'] ?? '' ) ),
                'sms_body'      => sanitize_textarea_field( wp_unslash( $_POST['followup_sms_body'] ?? '' ) ),
            ];
            update_option( 'paintops_followup_template', $template );
            echo '<div class="notice notice-success"><p>Follow-up template saved.</p></div>';
        }
        // --- Add a category from a template ---
        if ( isset( $_POST['paintops_add_category_template'] ) && check_admin_referer( 'paintops_add_category_template_action' ) ) {
            $template_key = sanitize_key( wp_unslash( $_POST['template_key'] ?? '' ) );
            $library = paintops_category_template_library();
            if ( isset( $library[$template_key] ) ) {
                $categories = paintops_job_categories();
                $next_id = ( empty( $categories ) ? 1 : max( array_keys( $categories ) ) + 1 );
                $categories[$next_id] = paintops_normalize_job_category( $library[$template_key] );
                update_option( 'paintops_job_categories', $categories );
                echo '<div class="notice notice-success"><p>&ldquo;' . esc_html( $library[$template_key]['name'] ) . '&rdquo; category added below - scroll down to Job Categories to customize or remove it.</p></div>';
            } else {
                echo '<div class="notice notice-error"><p>That template could not be found.</p></div>';
            }
        }
        $profile = paintops_company_profile();
        $rules = paintops_pricing_rules();
        $categories = paintops_job_categories();
        echo '<div class="wrap paintops-admin-wrap">';
        echo '<h1>PaintOps Business Settings</h1>';
        echo '<p class="description">This information is used to fill in your quotes, quote emails, and pricing suggestions. These used to be hardcoded for one specific business - now they\'re yours to edit. Click a section below to open it.</p>';
        // Each section below is a native <details> disclosure, collapsed by
        // default so a page that used to be one long unbroken scroll of every
        // setting at once now only shows what you actually came here to
        // change. No JS required for the open/close behavior itself; the
        // small counts next to each title are just a preview of what's
        // inside without having to open it.
        paintops_echo_settings_accordion_styles();
        // Company profile form.
        echo wp_kses( '<details class="paintops-settings-section"><summary>Company Profile' . paintops_settings_summary_right() . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_company_profile_action' );
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row"><label for="company_name">Company Name</label></th><td><input type="text" id="company_name" name="company_name" class="regular-text" placeholder="Your Company Name, LLC" value="' . esc_attr( $profile['name'] ) . '"><p class="description">Shown on the PDF quote header, the quote email signature, and the public quote page customers open to accept/pay.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_address1">Address Line 1</label></th><td><input type="text" id="company_address1" name="company_address1" class="regular-text" placeholder="123 Main St, Ste 100" value="' . esc_attr( $profile['address1'] ) . '"><p class="description">Shown with Address Line 2 below it on the PDF quote header and the public quote page.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_address2">Address Line 2 (City, State ZIP)</label></th><td><input type="text" id="company_address2" name="company_address2" class="regular-text" placeholder="City, ST 00000" value="' . esc_attr( $profile['address2'] ) . '"></td></tr>';
        echo '<tr><th scope="row"><label for="company_country">Country</label></th><td><select id="company_country" name="company_country">';
        foreach ( paintops_address_format_options() as $country_code => $country_format ) {
            echo '<option value="' . esc_attr( $country_code ) . '"' . selected( $profile['country'] ?? 'US', $country_code, false ) . '>' . esc_html( $country_format['label'] ) . '</option>';
        }
        echo '</select><p class="description">Changes the State/Zip field labels and examples shown on the New Request and Customer forms (e.g. "Province" and "Postal Code" for Canada). Doesn\'t restrict what can actually be typed in, just what the form calls things.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_currency">Currency</label></th><td><select id="company_currency" name="company_currency">';
        foreach ( paintops_supported_currencies() as $currency_code => $currency_info ) {
            echo '<option value="' . esc_attr( $currency_code ) . '"' . selected( paintops_currency_code(), $currency_code, false ) . '>' . esc_html( $currency_info['label'] ) . '</option>';
        }
        echo '</select><p class="description">The currency customers are actually charged in when they pay a deposit online through Stripe, and what symbol shows next to every dollar amount throughout the app.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_units">Measurement Units</label></th><td><select id="company_units" name="company_units">';
        foreach ( paintops_measurement_units_options() as $units_code => $units_info ) {
            echo '<option value="' . esc_attr( $units_code ) . '"' . selected( paintops_measurement_unit(), $units_code, false ) . '>' . esc_html( $units_info['label'] ) . '</option>';
        }
        echo '</select><p class="description">Switches the built-in Paint Calculator\'s fields to meters/sq meters (and leads the Price List\'s Unit dropdown with metric options). Doesn\'t restrict what can actually be typed in, just what the tools call things and start with.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_phone">Phone</label></th><td><input type="text" id="company_phone" name="company_phone" class="regular-text" placeholder="(555) 555-5555" value="' . esc_attr( $profile['phone'] ) . '"><p class="description">Shown in the signature line of the quote email sent to customers.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_email">Email</label></th><td><input type="email" id="company_email" name="company_email" class="regular-text" placeholder="you@yourcompany.com" value="' . esc_attr( $profile['email'] ) . '"><p class="description">Shown on the PDF quote header, the quote email signature, and the public quote page.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_website">Website</label></th><td><input type="url" id="company_website" name="company_website" class="regular-text" placeholder="https://yourcompany.com" value="' . esc_attr( $profile['website'] ) . '"><p class="description">Used as the logo\'s click-through link on quotes, and shown (without "https://") in the quote email signature.</p></td></tr>';
        echo '<tr><th scope="row"><label for="company_logo">Logo URL</label></th><td><input type="url" id="company_logo" name="company_logo" class="regular-text" placeholder="https://yourcompany.com/logo.png" value="' . esc_attr( $profile['logo'] ) . '"><p class="description">Shown on the PDF quote header.</p></td></tr>';
        echo '<tr><th scope="row"><label for="job_photo_limit">Job Photo Limit</label></th><td><input type="number" id="job_photo_limit" name="job_photo_limit" class="small-text" min="1" step="1" value="' . esc_attr( (string) paintops_job_photo_limit() ) . '"><p class="description">Max number of photos allowed on a single job\'s Photos section. Defaults to 15 - raise it if your jobs need more documentation than that.</p></td></tr>';
        // Quote Terms & Conditions (2026-07-19, v1.0.105, Mike's pick:
        // business-level, written once). Shown collapsed-but-expandable on the
        // public quote page ABOVE the typed-name signature (so the signature
        // covers it) and in full on the printable quote. Empty = nothing
        // renders anywhere. The textarea seeds a starter contract when never
        // saved, it only reaches customers after the owner saves it.
        $quote_terms_saved = get_option( 'paintops_quote_terms', null );
        $quote_terms_value = ( $quote_terms_saved === null ? paintops_quote_terms_starter() : (string) $quote_terms_saved );
        echo '<tr><th scope="row"><label for="paintops_quote_terms">Quote Terms &amp; Conditions</label></th><td>' . '<textarea id="paintops_quote_terms" name="paintops_quote_terms" rows="10" class="large-text" style="font-size:13px;">' . esc_textarea( $quote_terms_value ) . '</textarea>' . '<p class="description">Shown on every customer quote page (expandable, above the signature line) and on the printable quote. ' . (( $quote_terms_saved === null ? '<strong>This starter text is NOT live yet</strong> - edit it (fill the [bracketed] parts) and Save to publish it. ' : '' )) . 'Clear the box and Save to show no terms. Have an attorney review your final wording; this starter is not legal advice.</p></td></tr>';
        echo '<tr><th scope="row"><label for="paintops_quote_valid_days">Quote Valid For (days)</label></th><td>' . '<input type="number" id="paintops_quote_valid_days" name="paintops_quote_valid_days" min="0" step="1" value="' . (int) paintops_quote_valid_days() . '" style="width:90px;">' . '<p class="description">Quotes print a valid-until date this many days after creation, and the customer accept page stops taking online acceptances once it passes. Set 0 to disable expiration.</p></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_company_profile" value="1" class="button button-primary">Save Company Profile</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Pricing rules form.
        // Vehicle & Mileage settings section, premium build only.
        if ( function_exists( 'paintops_render_vehicle_settings_section' ) ) {
            paintops_render_vehicle_settings_section();
        }
        // Quote and Invoice Branding credit, strictly opt-in on every tier.
        $branding_shown = paintops_show_powered_by();
        $branding_meta = ( $branding_shown ? 'Shown' : 'Hidden' );
        echo wp_kses( '<details class="paintops-settings-section"><summary>Quote &amp; Invoice Branding' . paintops_settings_summary_right( esc_html( $branding_meta ) ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">If you\'d like to support PaintOps, you can show a small <strong>' . esc_html( paintops_powered_by_label() ) . ' &middot; paintoperations.com</strong> credit at the bottom of your customer quotes and invoices (the printable/PDF documents, the public quote and invoice pages, and the quote/invoice emails). It stays off unless you turn it on here.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_branding_action' );
        echo '<p><label><input type="checkbox" name="paintops_show_powered_by" value="1"' . checked( $branding_shown, true, false ) . '> Show the &ldquo;' . esc_html( paintops_powered_by_label() ) . '&rdquo; credit on customer quotes and invoices</label></p>';
        echo '<p class="description">Your Company Profile branding (logo, name, address) is unaffected either way.</p>';
        echo '<p><button type="submit" name="paintops_save_branding" value="1" class="button button-primary">Save</button></p>';
        echo '</form>';
        echo '</div></details>';
        echo wp_kses( '<details class="paintops-settings-section"><summary>Pricing Rules' . paintops_settings_summary_right() . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">These drive the auto-filled ' . esc_html( strtolower( paintops_spelling( 'Labor', 'Labour' ) ) ) . ' cost suggestion, the itemized quote markup, and the deposit terms shown on quotes.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_pricing_action' );
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row"><label for="labor_rate_per_day">' . esc_html( paintops_spelling( 'Labor', 'Labour' ) ) . ' rate (' . esc_html( paintops_currency_symbol() ) . ' / worker / day)</label></th><td><input type="number" step="1" min="0" id="labor_rate_per_day" name="labor_rate_per_day" value="' . esc_attr( (string) $rules['labor_rate_per_day'] ) . '"><p class="description">Used to pre-fill the suggested ' . esc_html( strtolower( paintops_spelling( 'Labor', 'Labour' ) ) ) . ' cost line on a new estimate (days &times; workers &times; this rate). You can still edit that line by hand before sending.</p></td></tr>';
        echo '<tr><th scope="row"><label for="markup_percent">Itemized quote markup (%)</label></th><td><input type="number" step="1" min="0" id="markup_percent" name="markup_percent" value="' . esc_attr( (string) $rules['markup_percent'] ) . '"><p class="description">Applied on top of the itemized subtotal to suggest a quote total on the New/Edit Quote screen\'s calculator.</p></td></tr>';
        echo '<tr><th scope="row"><label for="deposit_high_threshold">Large-job threshold (' . esc_html( paintops_currency_symbol() ) . ')</label></th><td><input type="number" step="1" min="0" id="deposit_high_threshold" name="deposit_high_threshold" value="' . esc_attr( (string) $rules['deposit_high_threshold'] ) . '"> <span class="description">Quotes above this use the large-job deposit %</span></td></tr>';
        echo '<tr><th scope="row"><label for="deposit_high_percent">Large-job deposit (%)</label></th><td><input type="number" step="1" min="0" max="100" id="deposit_high_percent" name="deposit_high_percent" value="' . esc_attr( (string) $rules['deposit_high_percent'] ) . '"></td></tr>';
        echo '<tr><th scope="row"><label for="deposit_mid_threshold">Medium-job threshold (' . esc_html( paintops_currency_symbol() ) . ')</label></th><td><input type="number" step="1" min="0" id="deposit_mid_threshold" name="deposit_mid_threshold" value="' . esc_attr( (string) $rules['deposit_mid_threshold'] ) . '"> <span class="description">Quotes above this (and below the large-job threshold) use the medium-job deposit %</span></td></tr>';
        echo '<tr><th scope="row"><label for="deposit_mid_percent">Medium-job deposit (%)</label></th><td><input type="number" step="1" min="0" max="100" id="deposit_mid_percent" name="deposit_mid_percent" value="' . esc_attr( (string) $rules['deposit_mid_percent'] ) . '"></td></tr>';
        echo '<tr><th scope="row"><label for="card_fee_percent">Credit card processing fee (%)</label></th><td><input type="number" step="0.1" min="0" id="card_fee_percent" name="card_fee_percent" value="' . esc_attr( (string) $rules['card_fee_percent'] ) . '"><p class="description">Disclosed to the customer right on the quote (e.g. "there is a 3.5% processing fee") when card is offered as a payment option. Set this to match what your card processor actually charges you.</p></td></tr>';
        echo '<tr><th scope="row"><label for="tax_label">Tax label</label></th><td><input type="text" id="tax_label" name="tax_label" class="regular-text" value="' . esc_attr( (string) $rules['tax_label'] ) . '" placeholder="Sales Tax"><p class="description">Whatever your customers actually call it - "Sales Tax", "VAT", "GST", "HST", etc. Shown as its own line on the itemize card, the PDF quote, and the customer-facing quote page.</p></td></tr>';
        echo '<tr><th scope="row"><label for="tax_percent">Tax rate (%)</label></th><td><input type="number" step="0.01" min="0" id="tax_percent" name="tax_percent" value="' . esc_attr( (string) $rules['tax_percent'] ) . '"><p class="description">Applied to the marked-up quote total. Leave at 0 to not show or charge any tax at all - nothing changes on the quote unless this is above zero.</p></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_pricing" value="1" class="button button-primary">Save Pricing Rules</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Job categories editor.
        echo wp_kses( '<details class="paintops-settings-section"><summary>Job Categories' . paintops_settings_summary_right( esc_html( (string) count( $categories ) ) . ' categories' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">These appear in the job form dropdown, the calendar, and the PDF quote headline. Removing a category does not delete jobs already using it - they\'ll just show as an unlabeled category number. Each category can also carry its own quoting tips, resource links, and calculator - this is what lets PaintOps support trades beyond painting without any code changes.</p>';
        // "Add from Template" lives in its own set of small forms, each posting
        // independently, it must stay OUTSIDE the main categories form below
        // (browsers don't support nested <form> elements).
        echo '<h3>Add from Template</h3>';
        echo '<p class="description">Pre-built categories for painters who also take on other kinds of work - each one comes with its own standards, resource tips, and supply-field hints already filled in. Nothing here is added automatically; pick one only if it fits your business.</p>';
        $template_library = paintops_category_template_library();
        echo '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px;">';
        foreach ( $template_library as $template_key => $template ) {
            // display:flex column + flex-grow on the description is what makes
            // "Add This Category" land in the same bottom-left spot on every
            // card regardless of how long that card's description happens to
            // be, the parent row above is also flex/wrap, which stretches
            // every card in a row to match the tallest one, so the button
            // lines up horizontally across cards too, not just within one.
            echo '<form method="post" style="border:1px solid #ccd0d4;border-radius:4px;padding:10px 14px;background:#fff;width:220px;display:flex;flex-direction:column;">';
            wp_nonce_field( 'paintops_add_category_template_action' );
            echo '<input type="hidden" name="template_key" value="' . esc_attr( $template_key ) . '">';
            echo '<strong>' . esc_html( $template['name'] ) . '</strong>';
            echo '<p class="description" style="margin:4px 0 8px;flex:1 0 auto;">' . esc_html( wp_trim_words( wp_strip_all_tags( $template['standards'] ), 14 ) ) . '</p>';
            echo '<button type="submit" name="paintops_add_category_template" value="1" class="button" style="align-self:flex-start;">Add This Category</button>';
            echo '</form>';
        }
        echo '</div>';
        echo '<h3>Your Categories</h3>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_categories_action' );
        $row_index = 0;
        foreach ( $categories as $id => $cat ) {
            $cat = paintops_normalize_job_category( $cat );
            $hints = $cat['supply_hints'];
            echo '<fieldset style="border:1px solid #ccd0d4;border-radius:4px;padding:15px 20px 20px;margin-bottom:15px;background:#fff;">';
            echo '<legend style="padding:0 8px;"><strong>Category #' . esc_html( (string) $id ) . '</strong></legend>';
            echo '<input type="hidden" name="cat_id[]" value="' . esc_attr( (string) $id ) . '">';
            echo '<table class="form-table" role="presentation" style="margin-top:0;">';
            echo '<tr><th scope="row">Name</th><td><input type="text" name="cat_name[]" class="regular-text" value="' . esc_attr( $cat['name'] ) . '"><p class="description">Shown in the category dropdown, the calendar, and the PDF quote headline.</p></td></tr>';
            echo '<tr><th scope="row">Our Standards</th><td><textarea name="cat_standards[]" rows="3" style="width:100%;max-width:600px;">' . esc_textarea( $cat['standards'] ) . '</textarea><p class="description">Shown on the PDF quote, right under the job description - a good place for a line or two on how you approach the work (prep steps, materials used, etc.).</p></td></tr>';
            echo '<tr><th scope="row">Delete</th><td><label><input type="checkbox" name="cat_delete[' . esc_attr( (string) $row_index ) . ']" value="1"> Delete this category</label><p class="description">Jobs already using this category keep their number - they won\'t be relabeled or lost, just shown as an unlabeled category.</p></td></tr>';
            echo '</table>';
            // Scope Components: an optional checklist of common line items for
            // this category, shown as checkboxes on the New/Edit Quote screen.
            // 'Label' is only what shows in that checklist (a short punch-list
            // item like "Caulk windows & doors"); 'Scope text' is the fuller,
            // specific wording actually inserted into the quote's Description
            // of Work when checked (e.g. "Caulk windows and doors as needed.
            // Caulk will be applied to inner joints only."). Splitting the two
            // lets the checklist stay scannable while the quote itself still
            // reads as specific, deliberate wording rather than a bare label.
            $components = $cat['scope_components'];
            $applies_to_labels = [
                'interior' => 'Interior only',
                'exterior' => 'Exterior only',
                'both'     => 'Interior & Exterior',
            ];
            echo wp_kses( '<details class="paintops-settings-section" style="margin-top:4px;"><summary>Scope Components' . paintops_settings_summary_right( esc_html( (string) count( $components ) ) . ' items' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo '<p class="description">Shown as a checklist on the New/Edit Quote screen for jobs in this category. Checking one inserts its Scope Text into the Description of Work - a fast way to build out a specific, accurate quote instead of typing the same wording every time. Applies To narrows the checklist to match the Interior/Exterior checkboxes on the job itself, so an interior-only job doesn\'t show exterior-only items (and vice versa).</p>';
            foreach ( $components as $comp_id => $comp ) {
                $comp_display = ( ($comp['label'] ?? '') !== '' ? $comp['label'] : '(untitled item)' );
                $comp_applies_to = $comp['applies_to'] ?? 'both';
                echo '<details class="paintops-settings-entry"><summary>' . esc_html( $comp_display ) . '</summary><div class="paintops-settings-entry-body">';
                echo '<input type="hidden" name="cat_comp_id[' . esc_attr( (string) $row_index ) . '][]" value="' . esc_attr( (string) $comp_id ) . '">';
                echo '<table class="form-table" role="presentation" style="margin:0;">';
                echo '<tr><th scope="row" style="width:140px;">Checklist label</th><td><input type="text" name="cat_comp_label[' . esc_attr( (string) $row_index ) . '][]" class="regular-text" value="' . esc_attr( $comp['label'] ?? '' ) . '"><p class="description">Short - this is all that shows in the checklist itself.</p></td></tr>';
                echo '<tr><th scope="row">Scope text</th><td><textarea name="cat_comp_text[' . esc_attr( (string) $row_index ) . '][]" rows="2" style="width:100%;max-width:700px;">' . esc_textarea( $comp['text'] ?? '' ) . '</textarea><p class="description">Inserted into the Description of Work when this item is checked - write it the way you want the customer to read it.</p></td></tr>';
                echo '<tr><th scope="row">Applies to</th><td><select name="cat_comp_applies_to[' . esc_attr( (string) $row_index ) . '][]">';
                foreach ( $applies_to_labels as $val => $lbl ) {
                    echo '<option value="' . esc_attr( $val ) . '"' . selected( $comp_applies_to, $val, false ) . '>' . esc_html( $lbl ) . '</option>';
                }
                echo '</select></td></tr>';
                echo '<tr><th scope="row">Delete</th><td><label><input type="checkbox" name="cat_comp_delete[' . esc_attr( (string) $row_index ) . '][' . esc_attr( (string) $comp_id ) . ']" value="1"> Delete this item</label></td></tr>';
                echo '</table>';
                echo '</div></details>';
            }
            echo '<h4 style="margin-bottom:6px;">Add a Scope Component</h4>';
            echo '<table class="form-table" role="presentation" style="margin-top:0;">';
            echo '<tr><th scope="row" style="width:140px;">Checklist label</th><td><input type="text" name="new_cat_comp_label[' . esc_attr( (string) $row_index ) . ']" class="regular-text" placeholder="i.e. Caulk windows &amp; doors"></td></tr>';
            echo '<tr><th scope="row">Scope text</th><td><textarea name="new_cat_comp_text[' . esc_attr( (string) $row_index ) . ']" rows="2" style="width:100%;max-width:700px;" placeholder="i.e. Caulk windows and doors as needed. Caulk will be applied to inner joints only."></textarea></td></tr>';
            echo '<tr><th scope="row">Applies to</th><td><select name="new_cat_comp_applies_to[' . esc_attr( (string) $row_index ) . ']">';
            foreach ( $applies_to_labels as $val => $lbl ) {
                echo '<option value="' . esc_attr( $val ) . '"' . selected( $val, 'both', false ) . '>' . esc_html( $lbl ) . '</option>';
            }
            echo '</select></td></tr>';
            echo '</table>';
            echo '</div></details>';
            // The rest is optional polish, not required for a category to
            // function, tucked behind its own toggle so a category with none
            // of it filled in doesn't clutter the page with empty-looking
            // fields. Only expand this if you actually use a calculator or
            // resource tips.
            //
            // Note: this used to also have "Quote note" and "Job Description
            // template" fields here. Both were removed, a fixed per-category
            // template was less useful than real data: Job Description now
            // relies solely on the "Copy Title & Description From a Past Job"
            // picker on the job form (real past text beats a generic
            // template), and Quote note's job (a reusable reminder, optionally
            // on by default) is now covered by Situational Clauses below,
            // which are editable per quote instead of silently fixed. Any
            // quote_note text that was already set was carried forward
            // automatically into a new default-on situational clause.
            echo '<details style="margin-top:4px;"><summary style="cursor:pointer;color:#787c82;font-size:13px;">Advanced (calculators, hints)</summary>';
            echo '<table class="form-table" role="presentation" style="margin-top:8px;">';
            $calculator_options = ( function_exists( 'paintops_calculator_options' ) ? paintops_calculator_options() : [
                '' => 'None',
            ] );
            $row_calculators = (array) ($cat['calculators'] ?? []);
            echo '<tr><th scope="row">Calculators</th><td>';
            foreach ( $calculator_options as $calc_key => $calc_label ) {
                if ( $calc_key === '' ) {
                    continue;
                    // no explicit "None" checkbox, leaving everything unchecked already means none
                }
                $checked = in_array( $calc_key, $row_calculators, true );
                echo '<label style="display:block;margin-bottom:6px;font-weight:normal;"><input type="checkbox" name="cat_calculators[' . esc_attr( (string) $row_index ) . '][]" value="' . esc_attr( $calc_key ) . '"' . checked( $checked, true, false ) . '> ' . esc_html( $calc_label ) . '</label>';
            }
            echo '<p class="description">Pick as many as apply, each one shows as its own tool on the New/Edit Quote screen for jobs in this category (e.g. an interior residential category might offer Paint, Cabinet &amp; Trim, and ' . esc_html( paintops_spelling( 'Labor', 'Labour' ) ) . ' Days together).</p></td></tr>';
            echo '</table>';
            echo '<div class="paintops-settings-info"><strong>Supply field hints:</strong> the 5 grey placeholder examples shown in the itemized Labor/Supply line items on the New/Edit Quote screen ("[1] Total Labor Description" and "[2]-[5] Supply Description"). These fields are hidden by default - click "Click to Itemize Expenses" (just under the Brief Marketing Headline field) to reveal them. That\'s a different toggle from "Show itemized price breakdown to the customer?" just above it, which only controls what the customer sees on the final quote. The hints themselves are just visual examples - nothing is saved unless the estimator types over them.</div>';
            echo '<table class="form-table" role="presentation" style="margin-top:0;">';
            echo '<tr><th scope="row">Supply field hints</th><td>';
            for ($h = 0; $h < 5; $h++) {
                echo wp_kses( '<input type="text" name="cat_supply_hint_' . ($h + 1) . '[]" value="' . esc_attr( $hints[$h] ?? '' ) . '" placeholder="Supply hint ' . ($h + 1) . '" style="width:19%;margin:0 1% 4px 0;">', paintops_kses_allowed_html(), paintops_kses_protocols() );
            }
            echo '<p class="description">Hint 1 &rarr; the "[1] Total Labor Description" field. Hints 2-5 &rarr; the four "Supply Description" fields.</p></td></tr>';
            echo '</table>';
            echo '<table class="form-table" role="presentation" style="margin-top:0;">';
            // Resource tip title/content fields removed 2026-07-22 (feature
            // retired entirely, Mike: admin clutter; render side removed in
            // 1.0.152/1.0.153). The resource_title/resource_links keys survive
            // in the data model as empty strings only.
            echo '</table>';
            echo '</details>';
            echo '</fieldset>';
            $row_index++;
        }
        echo '<h3>Add a Category</h3>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row"><label for="new_cat_name">Name</label></th><td><input type="text" id="new_cat_name" name="new_cat_name" class="regular-text"></td></tr>';
        echo '<tr><th scope="row"><label for="new_cat_standards">Our Standards</label></th><td><textarea id="new_cat_standards" name="new_cat_standards" rows="3" style="width:100%;"></textarea></td></tr>';
        echo '</table>';
        echo '<p class="description">New categories start without a calculator or resource tips - save first, then edit the new category above to fill those in.</p>';
        echo '<p><button type="submit" name="paintops_save_categories" value="1" class="button button-primary">Save Job Categories</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Job Classifications editor.
        $classifications = paintops_job_classifications();
        echo wp_kses( '<details class="paintops-settings-section"><summary>Job Classifications' . paintops_settings_summary_right( esc_html( (string) count( $classifications ) ) . ' classifications' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">A second, independent label from Job Category above, this describes the nature of how the job came about (routine repaint, new construction, a referral channel, storm/insurance work, etc.), used to break down profit by classification on the Profits page.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_job_classifications_action' );
        echo '<div class="paintops-settings-info"><strong>Where this shows up:</strong> as the "Job Classification" dropdown on the New Job/Quote form, and as the row label on the Profit By Job Classification report (Profits page) once a job with that classification has financials.</div>';
        foreach ( $classifications as $id => $label ) {
            echo '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">';
            echo '<input type="hidden" name="class_id[]" value="' . esc_attr( (string) $id ) . '">';
            echo '<input type="text" name="class_label[]" class="regular-text" value="' . esc_attr( $label ) . '">';
            echo '<label style="font-weight:normal;"><input type="checkbox" name="class_delete[' . esc_attr( (string) $id ) . ']" value="1"> Delete</label>';
            echo '</div>';
        }
        echo '<p><label for="new_class_label"><strong>Add a Classification:</strong></label> <input type="text" id="new_class_label" name="new_class_label" class="regular-text"></p>';
        echo '<p><button type="submit" name="paintops_save_job_classifications" value="1" class="button button-primary">Save Job Classifications</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Exclusions library, named to match the customer/staff-facing
        // "Exclusions" label (see paintops_render_quote_body_fragment() in
        // customer-payments.php). Internal function/option names still say
        // "scope" (paintops_scope_terms(), scope_notes column, etc.),
        // renaming those touches many call sites for no user-visible
        // benefit, so only the displayed text changed here.
        $scope_terms = paintops_scope_terms();
        $situational_clauses = paintops_scope_situational_clauses();
        echo wp_kses( '<details class="paintops-settings-section"><summary>Exclusions Library' . paintops_settings_summary_right( esc_html( (string) count( $scope_terms ) ) . ' terms, ' . esc_html( (string) count( $situational_clauses ) ) . ' clauses' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">Powers the Exclusions helper on the New/Edit Quote screen - catching wording that\'s easy to write but easy for a customer to misread (e.g. "caulk baseboards" reading as "caulk to the floor"), and putting the actual boundary in writing on the quote before anyone signs.</p>';
        echo '<h3>Scanned Terms</h3>';
        echo '<p class="description">While typing the quote wording, any of these words/phrases found will show a one-click suggestion to add its disclaimer to Exclusions.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_scope_terms_action' );
        foreach ( $scope_terms as $id => $term ) {
            $term_display = ( ($term['term'] ?? '') !== '' ? $term['term'] : '(untitled term)' );
            echo '<details class="paintops-settings-entry"><summary>' . esc_html( $term_display ) . '</summary><div class="paintops-settings-entry-body">';
            echo '<input type="hidden" name="term_id[]" value="' . esc_attr( (string) $id ) . '">';
            echo '<table class="form-table" role="presentation" style="margin:0;">';
            echo '<tr><th scope="row" style="width:140px;">Term</th><td><input type="text" name="term_word[]" class="regular-text" value="' . esc_attr( $term['term'] ?? '' ) . '"><p class="description">Matched against the quote wording as it\'s typed on the New/Edit Quote screen (not case-sensitive).</p></td></tr>';
            echo '<tr><th scope="row">Disclaimer</th><td><textarea name="term_disclaimer[]" rows="2" style="width:100%;max-width:700px;">' . esc_textarea( $term['disclaimer'] ?? '' ) . '</textarea><p class="description">Added to the quote\'s Exclusions section with one click when the Term above is spotted - this is the wording the customer actually sees on the quote.</p></td></tr>';
            echo '<tr><th scope="row">Delete</th><td><label><input type="checkbox" name="term_delete[' . esc_attr( (string) $id ) . ']" value="1"> Delete this term</label></td></tr>';
            echo '</table>';
            echo '</div></details>';
        }
        echo '<h4>Add a Term</h4>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row" style="width:140px;"><label for="new_term_word">Term</label></th><td><input type="text" id="new_term_word" name="new_term_word" class="regular-text"></td></tr>';
        echo '<tr><th scope="row"><label for="new_term_disclaimer">Disclaimer</label></th><td><textarea id="new_term_disclaimer" name="new_term_disclaimer" rows="2" style="width:100%;max-width:700px;"></textarea></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_scope_terms" value="1" class="button button-primary">Save Terms</button></p>';
        echo '</form>';
        echo '<h3>Situational Clauses</h3>';
        echo '<p class="description">Shown as a checklist on the New/Edit Quote screen regardless of what\'s typed - check "Default on" for any clause you always want pre-filled into Exclusions on new quotes.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_scope_situational_action' );
        foreach ( $situational_clauses as $id => $clause ) {
            $clause_display = ( ($clause['label'] ?? '') !== '' ? $clause['label'] : '(untitled clause)' );
            $default_meta = ( !empty( $clause['default_on'] ) ? ' <span class="paintops-settings-entry-meta">Default on</span>' : '' );
            echo wp_kses( '<details class="paintops-settings-entry"><summary>' . esc_html( $clause_display ) . $default_meta . '</summary><div class="paintops-settings-entry-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
            echo '<input type="hidden" name="sit_id[]" value="' . esc_attr( (string) $id ) . '">';
            echo '<table class="form-table" role="presentation" style="margin:0;">';
            echo '<tr><th scope="row" style="width:140px;">Label</th><td><input type="text" name="sit_label[]" class="regular-text" value="' . esc_attr( $clause['label'] ?? '' ) . '"><p class="description">The name shown on its checkbox in the Exclusions checklist on the New/Edit Quote screen.</p></td></tr>';
            echo '<tr><th scope="row">Disclaimer</th><td><textarea name="sit_disclaimer[]" rows="2" style="width:100%;max-width:700px;">' . esc_textarea( $clause['disclaimer'] ?? '' ) . '</textarea><p class="description">Added to the quote\'s Exclusions section when this checkbox is checked - this is the wording the customer actually sees on the quote.</p></td></tr>';
            echo '<tr><th scope="row">Default on</th><td><label><input type="checkbox" name="sit_default[' . esc_attr( (string) $id ) . ']" value="1"' . (( !empty( $clause['default_on'] ) ? ' checked' : '' )) . '> Pre-fill this clause into every new quote</label></td></tr>';
            echo '<tr><th scope="row">Delete</th><td><label><input type="checkbox" name="sit_delete[' . esc_attr( (string) $id ) . ']" value="1"> Delete this clause</label></td></tr>';
            echo '</table>';
            echo '</div></details>';
        }
        echo '<h4>Add a Clause</h4>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row" style="width:140px;"><label for="new_sit_label">Label</label></th><td><input type="text" id="new_sit_label" name="new_sit_label" class="regular-text"></td></tr>';
        echo '<tr><th scope="row"><label for="new_sit_disclaimer">Disclaimer</label></th><td><textarea id="new_sit_disclaimer" name="new_sit_disclaimer" rows="2" style="width:100%;max-width:700px;"></textarea></td></tr>';
        echo '<tr><th scope="row">Default on</th><td><label><input type="checkbox" name="new_sit_default" value="1"> Pre-fill this clause into every new quote</label></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_scope_situational" value="1" class="button button-primary">Save Situational Clauses</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Price List (reference sheet).
        $price_list = paintops_price_list();
        $price_units = paintops_price_list_units();
        echo wp_kses( '<details class="paintops-settings-section"><summary>Price List' . paintops_settings_summary_right( esc_html( (string) count( $price_list ) ) . ' items' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">A quick reference sheet, not a calculator - seeded with rough US national-average painting costs so there\'s something useful here on day one. These are pulled from the middle of a wide published range, and every market\'s labor rates differ, so treat them as a starting point and adjust to match your own pricing. Available as a quick popup on the New/Edit Quote screen.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_price_list_action' );
        foreach ( $price_list as $id => $item ) {
            echo '<fieldset style="border:1px solid #ccd0d4;border-radius:4px;padding:12px 16px;margin-bottom:10px;background:#fff;">';
            echo '<input type="hidden" name="price_id[]" value="' . esc_attr( (string) $id ) . '">';
            echo '<table class="form-table" role="presentation" style="margin:0;">';
            echo '<tr><th scope="row" style="width:140px;">Label</th><td><input type="text" name="price_label[]" class="regular-text" value="' . esc_attr( $item['label'] ?? '' ) . '"><p class="description">The row name shown in the Price List popup on the New/Edit Quote screen.</p></td></tr>';
            echo '<tr><th scope="row">Unit</th><td><select name="price_unit[]">';
            foreach ( $price_units as $unit_key => $unit_label ) {
                echo '<option value="' . esc_attr( $unit_key ) . '"' . selected( $item['unit'] ?? 'flat', $unit_key, false ) . '>' . esc_html( $unit_label ) . '</option>';
            }
            echo '</select><p class="description">How the Price (' . esc_html( paintops_currency_symbol() ) . ') below is labeled next to it (per linear ft, per sq ft, or a flat price).</p></td></tr>';
            echo '<tr><th scope="row">Price (' . esc_html( paintops_currency_symbol() ) . ')</th><td><input type="number" step="0.01" min="0" name="price_amount[]" value="' . esc_attr( (string) ($item['price'] ?? 0) ) . '"><p class="description">Reference only - not used in any calculation. It\'s just what shows next to the label in the popup.</p></td></tr>';
            echo '<tr><th scope="row">Note</th><td><input type="text" name="price_note[]" class="regular-text" value="' . esc_attr( $item['note'] ?? '' ) . '" placeholder="e.g. published range this was drawn from"><p class="description">Optional. Shown in its own "Note" column next to the price in the popup, e.g. a source or a range.</p></td></tr>';
            echo '<tr><th scope="row">Delete</th><td><label><input type="checkbox" name="price_delete[' . esc_attr( (string) $id ) . ']" value="1"> Delete this item</label></td></tr>';
            echo '</table>';
            echo '</fieldset>';
        }
        echo '<h4>Add an Item</h4>';
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row" style="width:140px;"><label for="new_price_label">Label</label></th><td><input type="text" id="new_price_label" name="new_price_label" class="regular-text"></td></tr>';
        echo '<tr><th scope="row">Unit</th><td><select id="new_price_unit" name="new_price_unit">';
        foreach ( $price_units as $unit_key => $unit_label ) {
            echo '<option value="' . esc_attr( $unit_key ) . '">' . esc_html( $unit_label ) . '</option>';
        }
        echo '</select></td></tr>';
        echo '<tr><th scope="row"><label for="new_price_amount">Price (' . esc_html( paintops_currency_symbol() ) . ')</label></th><td><input type="number" step="0.01" min="0" id="new_price_amount" name="new_price_amount"></td></tr>';
        echo '<tr><th scope="row"><label for="new_price_note">Note</label></th><td><input type="text" id="new_price_note" name="new_price_note" class="regular-text"></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_price_list" value="1" class="button button-primary">Save Price List</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Reusable Documents, standing PDFs (insurance cert, contract
        // terms, etc.) offered as one-click attachments on the Email This
        // Quote form (estimates.php), instead of digging up and re-attaching
        // the same file by hand on every quote.
        $documents = paintops_documents_library();
        echo wp_kses( '<details class="paintops-settings-section"><summary>Reusable Documents' . paintops_settings_summary_right( esc_html( (string) count( $documents ) ) . ' documents' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">Upload standing paperwork once (insurance certificate, standard contract terms, W-9, etc.) and it shows up as a one-click checkbox to attach on the Email This Quote form - no more digging up and re-attaching the same file by hand every time.</p>';
        if ( !empty( $documents ) ) {
            echo '<form method="post">';
            wp_nonce_field( 'paintops_save_documents_action' );
            foreach ( $documents as $id => $doc ) {
                $doc_label = ( ($doc['label'] ?? '') !== '' ? $doc['label'] : '(untitled document)' );
                echo '<div style="display:flex;align-items:center;gap:10px;border:1px solid #ccd0d4;border-radius:4px;padding:10px 14px;margin-bottom:8px;background:#fff;">';
                echo '<input type="hidden" name="doc_id[]" value="' . esc_attr( (string) $id ) . '">';
                echo '<input type="text" name="doc_label[]" class="regular-text" value="' . esc_attr( $doc_label ) . '" style="flex:1;">';
                if ( !empty( $doc['url'] ) ) {
                    echo '<a href="' . esc_url( $doc['url'] ) . '" target="_blank" rel="noopener noreferrer" class="button button-secondary">View</a>';
                }
                echo '<label style="font-weight:normal;white-space:nowrap;"><input type="checkbox" name="doc_delete[' . esc_attr( (string) $id ) . ']" value="1"> Delete</label>';
                echo '</div>';
            }
            echo '<p><button type="submit" name="paintops_save_documents" value="1" class="button button-primary">Save Documents</button></p>';
            echo '</form>';
        } else {
            echo '<p class="description">No documents uploaded yet.</p>';
        }
        echo '<h4>Upload a Document</h4>';
        echo '<form method="post" enctype="multipart/form-data">';
        wp_nonce_field( 'paintops_upload_document_action' );
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row" style="width:140px;"><label for="new_doc_label">Label</label></th><td><input type="text" id="new_doc_label" name="new_doc_label" class="regular-text" placeholder="e.g. Certificate of Insurance"></td></tr>';
        echo '<tr><th scope="row"><label for="new_doc_file">File</label></th><td><input type="file" id="new_doc_file" name="new_doc_file"></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_upload_document" value="1" class="button button-primary">Upload Document</button></p>';
        echo '</form>';
        echo '</div></details>';
        // Follow-Up Templates, the email subject/body and SMS text used
        // when nudging a customer who hasn't accepted a quote yet. Kept
        // editable here so Mike can tune the wording without a code change.
        $followup = paintops_followup_template();
        echo wp_kses( '<details class="paintops-settings-section"><summary>Follow-Up Templates' . paintops_settings_summary_right( '' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">Used by the &ldquo;Send Follow-Up&rdquo; email and text actions on stale quotes. Placeholders: <code>{customer_name}</code>, <code>{business_name}</code>, <code>{quote_link}</code>.</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_followup_template_action' );
        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row"><label for="followup_email_subject">Email subject</label></th><td><input type="text" id="followup_email_subject" name="followup_email_subject" class="regular-text" value="' . esc_attr( $followup['email_subject'] ) . '"></td></tr>';
        echo '<tr><th scope="row"><label for="followup_email_body">Email body</label></th><td><textarea id="followup_email_body" name="followup_email_body" rows="8" class="large-text">' . esc_textarea( $followup['email_body'] ) . '</textarea></td></tr>';
        echo '<tr><th scope="row"><label for="followup_sms_body">Text message</label></th><td><textarea id="followup_sms_body" name="followup_sms_body" rows="3" class="large-text">' . esc_textarea( $followup['sms_body'] ) . '</textarea></td></tr>';
        echo '</table>';
        echo '<p><button type="submit" name="paintops_save_followup_template" value="1" class="button button-primary">Save Follow-Up Template</button></p>';
        echo '</form>';
        echo '</div></details>';
        echo '</div>';
    }

    /**
     * Add a simple PaintOps WP admin menu.
     */
    function paintops_register_admin_menu() : void {
        $dashboard_hook = add_menu_page(
            'PaintOps',
            'PaintOps',
            'edit_posts',
            'paintops',
            'paintops_render_admin_page',
            'dashicons-admin-tools',
            26
        );
        // Send people straight to the real app page instead of landing on a
        // stub page with a button they then have to click again. This has to
        // happen on the "load-*" hook (fires before admin-header.php outputs
        // any HTML) rather than inside paintops_render_admin_page() itself,
        // by the time that callback runs, WP has already started sending the
        // admin page header, and a redirect can't be issued after that.
        add_action( "load-{$dashboard_hook}", 'paintops_redirect_dashboard_to_app' );
        add_submenu_page(
            'paintops',
            'Dashboard',
            'Dashboard',
            'edit_posts',
            'paintops',
            'paintops_render_admin_page'
        );
        add_submenu_page(
            'paintops',
            'Business',
            'Business',
            'manage_options',
            'paintops-business',
            'paintops_render_business_settings_page'
        );
        add_submenu_page(
            'paintops',
            'Painters/Helpers',
            'Painters/Helpers',
            'manage_options',
            'paintops-workers',
            'paintops_render_workers_page'
        );
        add_submenu_page(
            'paintops',
            'Settings',
            'Settings',
            'manage_options',
            'paintops-settings',
            'paintops_render_settings_page'
        );
    }

    /**
     * WP-admin "Painters/Helpers" page. Shows your current Painters/Helpers
     * plus site Administrators by default (so a one-person shop's own admin
     * account shows up without having to search for itself), and lets you
     * search by name/username/email to find and add anyone else. Lets you
     * toggle the Painter/Helper role on or off and set an hourly pay rate for
     * anyone paid that way. The rate feeds directly into job costing (see
     * paintops_get_worker_hourly_rate() and paintops_job_financial_summary()) so
     * profit-per-hour reflects what was actually paid out, not just gross
     * revenue.
     */
    /**
     * A compact, visual "how this page works" guide shown above the workers
     * table. Driven by paintops_worker_permissions() so the labels, defaults,
     * and short summaries stay in one place; the full detail still lives in the
     * hover tooltip on each checkbox in the table below.
     */
    function paintops_render_workers_guide() : string {
        $perms = paintops_worker_permissions();
        ob_start();
        ?>
    <?php 
        paintops_enqueue_style( 'workers-guide' );
        ?>
    <div class="po-guide">
      <h2>Quick guide</h2>
      <p>Check <strong>Painter/Helper</strong> for anyone who works jobs and is paid hourly, then set their <strong>hourly rate</strong>. Their rate times the hours they log on a job counts as a real labor cost, so profit-per-hour reflects what you actually paid out. Leave someone unchecked (like yourself, if you are paid by the job rather than the hour) and their logged hours still count toward the job's total hours, just not as a cost.</p>
      <div class="po-basics"><strong>Every Painter/Helper can already:</strong> view their own assigned jobs and open the emailed job link. The permissions below only add abilities beyond that.</div>
      <table class="po-perm-table">
        <thead><tr><th>Permission</th><th>Default</th><th>What it allows</th></tr></thead>
        <tbody>
        <?php 
        foreach ( $perms as $info ) {
            $on = !empty( $info['default'] );
            ?>
          <tr>
            <td><?php 
            echo esc_html( $info['label'] );
            ?></td>
            <td><span class="po-badge <?php 
            echo esc_attr( ( $on ? 'po-badge-on' : 'po-badge-off' ) );
            ?>"><?php 
            echo esc_attr( ( $on ? 'On' : 'Off' ) );
            ?></span></td>
            <td><?php 
            echo esc_html( (string) ($info['summary'] ?? '') );
            ?></td>
          </tr>
        <?php 
        }
        ?>
        </tbody>
      </table>
      <p style="margin-top:0;">Photos and receipts are <strong>on</strong> by default (matching how workers already worked). The other three are <strong>off</strong> until you check them for someone you trust. Hover the <span class="dashicons dashicons-editor-help" style="font-size:14px;width:14px;height:14px;vertical-align:text-top;color:#888;"></span> beside a permission in the table below for the exact details.</p>
      <ul class="po-finer">
        <li><strong>Nickname:</strong> a short name (like "Joe" or "David") shown on the job list and job assignments instead of the WP username. Leave it blank to fall back to the person's display name.</li>
        <li><strong>Phone (for job contact):</strong> when a number is set, it appears as a one-tap Call/Text link on the job view for others assigned to the same job, so the crew can coordinate on site. Add a number only if the person has agreed to be contacted by their teammates. It is shown only to others on the same job, never to customers.</li>
      </ul>
    </div>
    <?php 
        return (string) ob_get_clean();
    }

    // paintops_render_add_painter_help() (the "Adding someone new to the
    // team" drawer) was removed 2026-07-22: it described the pre-1.0.126
    // WP-Users signup flow, made redundant by the one-step Option A card on
    // the Painters/Helpers page.
    /**
     * Crews (2026-07-19, v1.0.105): named, colored groups of workers. See the
     * cs_crews CREATE TABLE comment for the design (lightweight: crews bulk-
     * fill the job form's worker checkboxes and stamp jobs.crew_id for
     * filtering/reporting; jobs.reqWorkers stays the worker source of truth).
     * member_ids uses the same '#id#' convention as reqWorkers.
     */
    function paintops_get_crews(  bool $include_inactive = false  ) : array {
        global $wpdb;
        if ( $include_inactive ) {
            return ( $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i ORDER BY name ASC", paintops_table( 'crews' ) ) ) ?: [] );
        }
        return ( $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE active = 1 ORDER BY name ASC", paintops_table( 'crews' ) ) ) ?: [] );
    }

    function paintops_get_crew(  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( 'crews' ), $id ) );
    }

    /** '#1##5#' member string -> [1, 5]; shared '#id#' convention. */
    function paintops_crew_member_ids(  $crew  ) : array {
        return ( $crew ? paintops_parse_worker_ids( (string) ($crew->member_ids ?? '') ) : [] );
    }

    /**
     * Email a Painter/Helper their app invite: a ONE-TIME set-password link
     * (WordPress's own reset-key mechanism, so it expires in a day and dies
     * after use, deliberately NOT a permanent magic-login link, a forwarded
     * text must never be a working login), the app URL, and Add to Home
     * Screen instructions. Returns the pieces on success so the admin
     * notice can offer a copy-and-text version too (crews live in SMS), or
     * a WP_Error.
     *
     * @return array{set_url:string,app_url:string,sms:string,email:string}|WP_Error
     */
    function paintops_send_worker_invite(  int $user_id  ) {
        $user = get_user_by( 'id', $user_id );
        if ( !$user || !is_email( $user->user_email ) ) {
            return new WP_Error('paintops_invite_user', 'That user has no valid email address.');
        }
        $key = get_password_reset_key( $user );
        if ( is_wp_error( $key ) ) {
            return $key;
        }
        $app_url = paintops_app_url();
        $set_url = network_site_url( 'wp-login.php?action=rp&key=' . rawurlencode( $key ) . '&login=' . rawurlencode( $user->user_login ), 'login' );
        // Ride the app URL through the reset flow so the login form that
        // follows a successful password save lands them in the app.
        $set_url = add_query_arg( 'redirect_to', rawurlencode( $app_url ), $set_url );
        $company = ( function_exists( 'paintops_company_profile' ) ? paintops_company_profile() : [] );
        $biz = ( trim( (string) ($company['name'] ?? '') ) !== '' ? (string) $company['name'] : get_bloginfo( 'name' ) );
        $nickname = (string) get_user_meta( $user->ID, 'paintops_worker_nickname', true );
        $first = ( $nickname !== '' ? $nickname : (( trim( (string) $user->first_name ) !== '' ? $user->first_name : $user->display_name )) );
        $subject = $biz . ': your PaintOps app login';
        $body = "Hi {$first},\n\n" . "You're set up on {$biz}'s PaintOps app, where you'll see your job schedule and log photos, hours, and receipts.\n\n" . "1) Set your password (this link works once and expires in 24 hours):\n{$set_url}\n\n" . "2) That drops you into the app. Bookmark it:\n{$app_url}\n\n" . "3) On your phone, use \"Add to Home Screen\" so it opens like a regular app and works on job sites with weak signal.\n\n" . "Your username: {$user->user_login}\n\n" . $biz;
        $sent = wp_mail(
            $user->user_email,
            $subject,
            $body,
            paintops_mail_headers()
        );
        if ( !$sent ) {
            return new WP_Error('paintops_invite_mail', 'The invite email could not be sent. Check your site\'s email settings.');
        }
        $sms = "You're set up on {$biz}'s PaintOps app. Set your password (works once, expires in 24h): {$set_url} Then open {$app_url} and use Add to Home Screen.";
        return [
            'set_url' => $set_url,
            'app_url' => $app_url,
            'sms'     => $sms,
            'email'   => (string) $user->user_email,
        ];
    }

    /** Validate a #rrggbb hex color, falling back to the app default blue. */
    function paintops_sanitize_crew_color(  string $color  ) : string {
        return ( preg_match( '/^#[0-9a-fA-F]{6}$/', $color ) ? $color : '#4682B4' );
    }

    function paintops_render_workers_page() : void {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to manage PaintOps workers.', 'paintops' ) );
        }
        // --- Save: Time Clock + Team Login Duration (moved here from the
        // Business/Settings pages 2026-07-22, Mike's call: both are settings
        // ABOUT the team, so they live with the team) ---
        if ( isset( $_POST['paintops_save_timeclock'] ) && check_admin_referer( 'paintops_save_timeclock_action' ) ) {
            update_option( 'paintops_timeclock_enabled', ( !empty( $_POST['paintops_timeclock_enabled'] ) ? '1' : '0' ) );
            echo '<div class="notice notice-success"><p>Time Clock setting saved.</p></div>';
        }
        if ( isset( $_POST['paintops_save_login_duration'] ) && check_admin_referer( 'paintops_save_login_duration_action' ) ) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
            $login_days_input = (int) ($_POST['paintops_login_duration_days'] ?? 30);
            if ( !in_array( $login_days_input, paintops_login_duration_choices(), true ) ) {
                $login_days_input = 30;
            }
            update_option( 'paintops_login_duration_days', (string) $login_days_input );
            echo '<div class="notice notice-success"><p>Login duration saved. It applies to each team member the next time they log in.</p></div>';
        }
        $role_slug = paintops_worker_role_slug();
        // App invite (2026-07-21, Mike's onboarding request): per-row nonce
        // link, same GET-action pattern as the vehicle rows. Generates a
        // fresh one-time set-password key each click, so re-sending is the
        // fix for an expired link.
        if ( isset( $_GET['paintops_invite'] ) ) {
            $invite_uid = (int) $_GET['paintops_invite'];
            if ( $invite_uid > 0 && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'paintops_invite_' . $invite_uid ) ) {
                $invite = paintops_send_worker_invite( $invite_uid );
                if ( is_wp_error( $invite ) ) {
                    echo '<div class="notice notice-error"><p>' . esc_html( $invite->get_error_message() ) . '</p></div>';
                } else {
                    echo '<div class="notice notice-success"><p>Invite emailed to <strong>' . esc_html( $invite['email'] ) . '</strong> with a one-time set-password link and the app address.</p>';
                    echo '<p style="margin-bottom:4px;">Prefer to text it? Copy this (the link inside works once and expires in 24 hours):</p>';
                    echo wp_kses( '<textarea readonly onclick="this.select()" style="width:100%;max-width:640px;font-size:12px;" rows="3">' . esc_textarea( $invite['sms'] ) . '</textarea></div>', paintops_kses_allowed_html(), paintops_kses_protocols() );
                }
            } else {
                echo '<div class="notice notice-error"><p>That invite link is invalid or expired, please try again from the row below.</p></div>';
            }
        }
        // One-step "Add a new Painter/Helper" (2026-07-21, Mike's flow
        // review): creates the account WITH the role already attached and
        // sends ONLY the app invite. Creating users programmatically sends
        // no WordPress notification email, so the painter gets exactly one
        // message instead of the old double set-your-password pair (WP's
        // new-user email at creation + the invite later).
        if ( isset( $_POST['paintops_add_worker'] ) && check_admin_referer( 'paintops_add_worker_action' ) ) {
            $nw_name = sanitize_text_field( wp_unslash( $_POST['new_worker_name'] ?? '' ) );
            $nw_email = sanitize_email( wp_unslash( $_POST['new_worker_email'] ?? '' ) );
            $nw_phone = sanitize_text_field( wp_unslash( $_POST['new_worker_phone'] ?? '' ) );
            if ( $nw_name === '' || !is_email( $nw_email ) ) {
                echo '<div class="notice notice-error"><p>A name and a valid email address are both required.</p></div>';
            } elseif ( email_exists( $nw_email ) ) {
                echo '<div class="notice notice-error"><p>A user with that email already exists. Search for them below, tick Painter/Helper, Save, then use their Send app invite link.</p></div>';
            } else {
                // Username from the email's local part (their most memorable
                // handle), uniquified; falls back to the squashed name.
                $nw_base = sanitize_user( strtolower( (string) current( explode( '@', $nw_email ) ) ), true );
                if ( $nw_base === '' ) {
                    $nw_base = sanitize_user( strtolower( str_replace( ' ', '', $nw_name ) ), true );
                }
                if ( $nw_base === '' ) {
                    $nw_base = 'painter';
                }
                $nw_login = $nw_base;
                $nw_n = 1;
                while ( username_exists( $nw_login ) ) {
                    $nw_login = $nw_base . $nw_n;
                    $nw_n++;
                }
                // Deliberate, admin-initiated account creation: PaintOps is a team app and the site owner (manage_options, checked at the top of this function) creates accounts for their own painters here, behind the paintops_add_worker_action nonce above. The account gets the plugin's limited paintops_worker role, a random 24-char password the admin never sees, and access only via the emailed one-time set-password link, no self-registration, no login handling of our own (core wp_signon/cookies untouched).
                $nw_uid = wp_insert_user( [
                    'user_login'   => $nw_login,
                    'user_email'   => $nw_email,
                    'user_pass'    => wp_generate_password( 24 ),
                    'display_name' => $nw_name,
                    'first_name'   => (string) current( explode( ' ', $nw_name ) ),
                    'role'         => $role_slug,
                ] );
                if ( is_wp_error( $nw_uid ) ) {
                    echo '<div class="notice notice-error"><p>' . esc_html( $nw_uid->get_error_message() ) . '</p></div>';
                } else {
                    if ( $nw_phone !== '' ) {
                        update_user_meta( (int) $nw_uid, 'paintops_worker_phone', $nw_phone );
                    }
                    $invite = paintops_send_worker_invite( (int) $nw_uid );
                    if ( is_wp_error( $invite ) ) {
                        echo '<div class="notice notice-warning"><p>' . esc_html( $nw_name ) . ' was created as a Painter/Helper (username <code>' . esc_html( $nw_login ) . '</code>), but the invite email failed: ' . esc_html( $invite->get_error_message() ) . ' Use their Send app invite link below to retry.</p></div>';
                    } else {
                        echo '<div class="notice notice-success"><p><strong>' . esc_html( $nw_name ) . '</strong> was added as a Painter/Helper (username <code>' . esc_html( $nw_login ) . '</code>) and the app invite was emailed to <strong>' . esc_html( $invite['email'] ) . '</strong>.</p>';
                        echo '<p style="margin-bottom:4px;">Prefer to text it? Copy this (the link inside works once and expires in 24 hours):</p>';
                        echo wp_kses( '<textarea readonly onclick="this.select()" style="width:100%;max-width:640px;font-size:12px;" rows="3">' . esc_textarea( $invite['sms'] ) . '</textarea></div>', paintops_kses_allowed_html(), paintops_kses_protocols() );
                    }
                }
            }
        }
        // Crew CRUD (v1.0.105), handled before any output so the crew list
        // below reflects the change immediately. Same admin-only gate as the
        // rest of this page.
        if ( isset( $_POST['paintops_save_crew'] ) && check_admin_referer( 'paintops_crew_action' ) ) {
            global $wpdb;
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
            $crew_id = (int) ($_POST['crew_id'] ?? 0);
            $crew_name = sanitize_text_field( wp_unslash( $_POST['crew_name'] ?? '' ) );
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- paintops_sanitize_crew_color() reduces the value to a known safe palette value
            $crew_color = paintops_sanitize_crew_color( (string) ($_POST['crew_color'] ?? '') );
            $crew_members = array_values( array_unique( array_filter( array_map( 'intval', (array) ($_POST['crew_members'] ?? []) ) ) ) );
            $crew_member_str = ( $crew_members ? '#' . implode( '##', $crew_members ) . '#' : '' );
            $crew_active = ( !empty( $_POST['crew_active'] ) ? 1 : 0 );
            if ( $crew_name !== '' ) {
                $crew_data = [
                    'name'       => $crew_name,
                    'color'      => $crew_color,
                    'member_ids' => $crew_member_str,
                    'active'     => $crew_active,
                ];
                $crew_formats = [
                    '%s',
                    '%s',
                    '%s',
                    '%d'
                ];
                if ( $crew_id > 0 ) {
                    $wpdb->update(
                        paintops_table( 'crews' ),
                        $crew_data,
                        [
                            'id' => $crew_id,
                        ],
                        $crew_formats,
                        ['%d']
                    );
                } else {
                    $wpdb->insert( paintops_table( 'crews' ), $crew_data, $crew_formats );
                }
                echo '<div class="notice notice-success"><p>Crew saved.</p></div>';
            } else {
                echo '<div class="notice notice-error"><p>Give the crew a name.</p></div>';
            }
        }
        if ( isset( $_POST['paintops_delete_crew'] ) && check_admin_referer( 'paintops_crew_action' ) ) {
            global $wpdb;
            $del_id = (int) $_POST['paintops_delete_crew'];
            if ( $del_id > 0 ) {
                $wpdb->delete( paintops_table( 'crews' ), [
                    'id' => $del_id,
                ], ['%d'] );
                // Un-stamp jobs that referenced it; their workers are untouched
                // (reqWorkers is the source of truth, the crew was only a label).
                $wpdb->update(
                    paintops_table( 'jobs' ),
                    [
                        'crew_id' => 0,
                    ],
                    [
                        'crew_id' => $del_id,
                    ],
                    ['%d'],
                    ['%d']
                );
                echo '<div class="notice notice-success"><p>Crew deleted. Jobs kept their assigned workers.</p></div>';
            }
        }
        if ( isset( $_POST['paintops_save_workers'] ) && check_admin_referer( 'paintops_save_workers_action' ) ) {
            $selected_ids = array_map( 'intval', (array) ($_POST['worker_role'] ?? []) );
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- array is iterated below and each element is individually sanitized or cast where consumed
            $rates = (array) ($_POST['worker_rate'] ?? []);
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- array is iterated below and each element is individually sanitized or cast where consumed
            $phones = (array) ($_POST['worker_phone'] ?? []);
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- array is iterated below and each element is individually sanitized or cast where consumed
            $nicknames = (array) ($_POST['worker_nickname'] ?? []);
            // One checkbox list per permission, e.g. worker_perm['view_financials'][] = uid
            //, mirrors the worker_role[] pattern above, so a permission is "on"
            // for a user only if their ID appears in that permission's submitted list.
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- array is iterated below and each element is individually sanitized or cast where consumed
            $submitted_perms = (array) ($_POST['worker_perm'] ?? []);
            // Only reconcile the role for users that were actually rendered in
            // THIS submission (worker_known_id[], one hidden input per row
            // below), on a site with a lot of users, the table below only
            // ever shows current Painters/Helpers plus whoever a search
            // matched, never every user. If we instead looped every user on
            // the site (like this used to), submitting a filtered/searched
            // view would read as "nobody else has the role" and silently
            // strip Painter/Helper from everyone who just wasn't on screen.
            $known_ids = array_map( 'intval', (array) ($_POST['worker_known_id'] ?? []) );
            foreach ( $known_ids as $uid ) {
                $user = new WP_User($uid);
                if ( !$user->exists() ) {
                    continue;
                }
                $has_role = in_array( $role_slug, (array) $user->roles, true );
                $should_have_role = in_array( $uid, $selected_ids, true );
                if ( $should_have_role && !$has_role ) {
                    $user->add_role( $role_slug );
                } elseif ( !$should_have_role && $has_role ) {
                    $user->remove_role( $role_slug );
                }
                $rate = ( isset( $rates[$uid] ) ? max( 0.0, (float) $rates[$uid] ) : 0.0 );
                update_user_meta( $uid, 'paintops_hourly_rate', $rate );
                // Opt-in contact number for on-the-job communication. Stored as
                // free text (sanitized) so any local/international format works;
                // the tel:/sms: links derive their own digits at render time.
                $phone = ( isset( $phones[$uid] ) ? sanitize_text_field( wp_unslash( $phones[$uid] ) ) : '' );
                update_user_meta( $uid, 'paintops_worker_phone', $phone );
                // Short display nickname used on the job list in place of the
                // WP username. Free text, kept short.
                $nickname = ( isset( $nicknames[$uid] ) ? sanitize_text_field( wp_unslash( $nicknames[$uid] ) ) : '' );
                update_user_meta( $uid, 'paintops_worker_nickname', $nickname );
                foreach ( array_keys( paintops_worker_permissions() ) as $perm_key ) {
                    $perm_ids = array_map( 'intval', (array) ($submitted_perms[$perm_key] ?? []) );
                    $has_perm = in_array( $uid, $perm_ids, true );
                    update_user_meta( $uid, paintops_worker_permission_meta_key( $perm_key ), ( $has_perm ? '1' : '0' ) );
                }
            }
            echo '<div class="notice notice-success"><p>Painters/Helpers saved.</p></div>';
        }
        // Search, by name/username/email, on a site with a lot of users,
        // always loading get_users() with no filter (the old behavior) would
        // mean loading and rendering every single WP user on every page
        // load, which doesn't scale and isn't useful anyway (you came here to
        // manage a short list of workers, not browse your whole user base).
        // Default view: just your current Painters/Helpers (already a short,
        // relevant list). Searching looks across ALL users so you can find
        // and add someone new, capped at $search_limit results.
        $search_term = ( isset( $_GET['worker_search'] ) ? sanitize_text_field( wp_unslash( $_GET['worker_search'] ) ) : '' );
        $search_limit = 50;
        $result_note = '';
        if ( $search_term !== '' ) {
            $found_users = get_users( [
                'search'         => '*' . $search_term . '*',
                'search_columns' => ['user_login', 'user_email', 'display_name'],
                'orderby'        => 'display_name',
                'number'         => $search_limit,
            ] );
            $result_note = ( count( $found_users ) >= $search_limit ? 'Showing the first ' . $search_limit . ' matches - refine your search if you don\'t see who you\'re looking for.' : count( $found_users ) . ' match' . (( count( $found_users ) === 1 ? '' : 'es' )) . ' found.' );
        } else {
            // Current Painters/Helpers, plus Administrators, very common,
            // especially for a one-person shop, for the site's own admin to
            // also be the one out on jobs. Without this they'd never show up
            // here unless they searched for themselves. Administrator count
            // is realistically always small (unlike the full user base), so
            // this stays safe on a large site.
            $found_users = get_users( [
                'role__in' => [$role_slug, 'administrator'],
                'orderby'  => 'display_name',
            ] );
        }
        echo '<div class="wrap paintops-admin-wrap">';
        echo '<h1>Painters/Helpers</h1>';
        // Accordion sections (Mike 2026-07-23: "That page has grown"), the
        // same shared look as the Settings and Business admin screens. This
        // supersedes the 2026-07-22 "plain sections, no accordion" call for
        // Time Clock / Login Duration below, the page simply got too long
        // since (roster, add flows, crews, and two settings groups). The
        // Team Roster starts open, it's the daily check; any other section
        // whose form just POSTed opens too, so the saved result (or the
        // search box's Clear button) is on screen instead of hidden behind
        // a collapsed summary. EVERY section starts collapsed (Mike's
        // follow-up: the roster too), the Quick Guide lives inside Team
        // Roster below the table.
        paintops_echo_settings_accordion_styles();
        $acc_roster_open = isset( $_POST['paintops_save_workers'] ) || $search_term !== '';
        $acc_add_open = isset( $_POST['paintops_add_worker'] ) || $search_term !== '';
        $acc_crews_open = isset( $_POST['paintops_save_crew'] ) || isset( $_POST['paintops_delete_crew'] );
        $acc_tc_open = isset( $_POST['paintops_save_timeclock'] );
        $acc_login_open = isset( $_POST['paintops_save_login_duration'] );
        echo wp_kses( '<details class="paintops-settings-section"' . (( $acc_roster_open ? ' open' : '' )) . '><summary>Team Roster' . paintops_settings_summary_right( esc_html( (string) count( $found_users ) ) . ' shown' ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        if ( $search_term === '' ) {
            echo '<p class="description">Showing your current Painters/Helpers and site Administrators (check the box for yourself here if you also work jobs). Use Option A or B below to find and add anyone else.</p>';
        } elseif ( $result_note !== '' ) {
            echo wp_kses( '<p class="description">' . $result_note . '</p>', paintops_kses_allowed_html(), paintops_kses_protocols() );
        }
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_workers_action' );
        // Mobile scroll wrapper (2026-07-26).
        echo '<div style="overflow-x:auto;">';
        echo '<table class="widefat striped">';
        echo '<thead><tr><th>Name</th><th>Nickname</th><th>Email</th><th>Painter/Helper</th><th>Hourly Rate (' . esc_html( paintops_currency_symbol() ) . ')</th><th>Phone (for job contact)</th><th>Permissions</th></tr></thead>';
        echo '<tbody>';
        if ( empty( $found_users ) ) {
            echo '<tr><td colspan="7">' . (( $search_term !== '' ? 'No matching users found.' : 'No Painters/Helpers yet - use Option A or B below to add one.' )) . '</td></tr>';
        }
        $worker_permissions = paintops_worker_permissions();
        foreach ( $found_users as $user ) {
            $is_worker = in_array( $role_slug, (array) $user->roles, true );
            $rate = paintops_get_worker_hourly_rate( (int) $user->ID );
            // An Administrator already passes every one of these permission
            // checks unconditionally (paintops_user_can_manage() /
            // current_user_can('manage_options') is checked first and short-
            // circuits to true before the saved checkbox is ever consulted,
            // see paintops_user_can_view_financials()/paintops_user_can_create_job()/
            // paintops_user_can_create_quote()/paintops_user_can_manage_job_photos()/
            // paintops_user_can_add_job_receipt()). Showing live, toggleable
            // checkboxes for them would suggest unchecking one takes something
            // away, when it does nothing at all, so an admin row gets a plain
            // note instead, and never submits worker_perm[] entries for itself.
            $is_admin_user = in_array( 'administrator', (array) $user->roles, true );
            echo '<tr>';
            echo '<td>' . esc_html( $user->display_name ) . '</td>';
            $worker_nickname = paintops_get_worker_nickname( (int) $user->ID );
            echo '<td><input type="text" name="worker_nickname[' . esc_attr( (string) $user->ID ) . ']" value="' . esc_attr( $worker_nickname ) . '" placeholder="' . esc_attr( $user->display_name ) . '" style="width:120px;"></td>';
            echo '<td>' . esc_html( $user->user_email );
            $invite_url = wp_nonce_url( add_query_arg( 'paintops_invite', (int) $user->ID, admin_url( 'admin.php?page=paintops-workers' ) ), 'paintops_invite_' . (int) $user->ID );
            echo '<br><a href="' . esc_url( $invite_url ) . '" style="font-size:12px;white-space:nowrap;" title="Emails them a one-time set-password link, the app address, and Add to Home Screen steps. Tip: save the Painter/Helper checkbox first so the app shows them their jobs.">Send app invite</a>';
            echo '</td>';
            echo '<td><input type="hidden" name="worker_known_id[]" value="' . esc_attr( (string) $user->ID ) . '"><input type="checkbox" name="worker_role[]" value="' . esc_attr( (string) $user->ID ) . '"' . checked( $is_worker, true, false ) . '></td>';
            echo '<td><input type="text" name="worker_rate[' . esc_attr( (string) $user->ID ) . ']" value="' . esc_attr( ( $rate > 0 ? number_format(
                $rate,
                2,
                '.',
                ''
            ) : '' ) ) . '" placeholder="0.00" style="width:90px;"></td>';
            $worker_phone = paintops_get_worker_phone( (int) $user->ID );
            echo '<td><input type="tel" name="worker_phone[' . esc_attr( (string) $user->ID ) . ']" value="' . esc_attr( $worker_phone ) . '" placeholder="(555) 555-5555" autocomplete="off" style="width:140px;"></td>';
            echo '<td>';
            if ( $is_admin_user ) {
                echo '<span style="font-size:12px;color:#777;">Full access (Administrator)</span>';
            } else {
                foreach ( $worker_permissions as $perm_key => $perm_info ) {
                    $has_perm = paintops_worker_has_permission( $perm_key, (int) $user->ID );
                    echo '<label style="display:block;font-size:12px;white-space:nowrap;margin-bottom:2px;">';
                    echo '<input type="checkbox" name="worker_perm[' . esc_attr( $perm_key ) . '][]" value="' . esc_attr( (string) $user->ID ) . '"' . checked( $has_perm, true, false ) . '> ';
                    echo esc_html( $perm_info['label'] );
                    if ( !empty( $perm_info['tooltip'] ) ) {
                        echo ' <span class="dashicons dashicons-editor-help" style="font-size:14px;width:14px;height:14px;vertical-align:text-top;color:#888;cursor:help;" title="' . esc_attr( $perm_info['tooltip'] ) . '"></span>';
                    }
                    echo '</label>';
                }
            }
            echo '</td>';
            echo '</tr>';
        }
        echo '</tbody></table>';
        echo '</div>';
        echo '<p><button type="submit" name="paintops_save_workers" value="1" class="button button-primary">Save</button></p>';
        echo '</form>';
        echo wp_kses( paintops_render_workers_guide(), paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '</div></details>';
        // Add/find live BELOW the roster (Mike 2026-07-22): the table is what
        // you check daily, adding someone is the occasional task. Its heading
        // is now the accordion summary itself.
        echo wp_kses( '<details class="paintops-settings-section"' . (( $acc_add_open ? ' open' : '' )) . '><summary>Add Someone to the Team' . paintops_settings_summary_right() . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        // One step, one email: account + role + app invite together.
        echo '<div style="border:1px solid #c3c4c7;background:#fff;border-radius:8px;padding:14px 16px;margin:0 0 16px;max-width:720px;">';
        echo '<h3 style="margin:0 0 4px;">Option A: Add a New Painter/Helper</h3>';
        echo '<p class="description" style="margin:0 0 10px;">Creates their account with the Painter/Helper role and sends the app invite in one step, they get a single email with a set-password link that lands them in the app. (Adding people on the WordPress Users screen instead? Untick &ldquo;Send the new user an email&rdquo; there, then use Send app invite in the table above, so they are not asked to set a password twice.)</p>';
        echo '<form method="post" style="display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;">';
        wp_nonce_field( 'paintops_add_worker_action' );
        echo '<label style="display:block;font-size:12px;">Name<br><input type="text" name="new_worker_name" placeholder="e.g. Chris Ramirez" required style="width:180px;"></label>';
        echo '<label style="display:block;font-size:12px;">Email<br><input type="email" name="new_worker_email" placeholder="chris@example.com" required style="width:220px;"></label>';
        echo '<label style="display:block;font-size:12px;">Phone (optional)<br><input type="tel" name="new_worker_phone" placeholder="(555) 555-5555" style="width:150px;"></label>';
        echo '<button type="submit" name="paintops_add_worker" value="1" class="button button-primary">Create &amp; Send App Invite</button>';
        echo '</form>';
        echo '</div>';
        echo '<div style="border:1px solid #c3c4c7;background:#fff;border-radius:8px;padding:14px 16px;margin:0 0 16px;max-width:720px;">';
        echo '<h3 style="margin:0 0 4px;">Option B: Find an Existing User</h3>';
        echo '<p class="description" style="margin:0 0 10px;">Already have a WordPress account for them? Find it here, then tick <strong>Painter/Helper</strong> on their row in the table above and Save (search results replace the table).</p>';
        echo '<form method="get" style="margin:0;">';
        echo '<input type="hidden" name="page" value="paintops-workers">';
        echo '<label for="worker_search" class="screen-reader-text">Find someone by name, username, or email</label>';
        echo '<input type="text" id="worker_search" name="worker_search" value="' . esc_attr( $search_term ) . '" placeholder="Name, username, or email" style="max-width:280px;">';
        echo ' <button type="submit" class="button">Search</button>';
        if ( $search_term !== '' ) {
            echo ' <a class="button" href="' . esc_url( remove_query_arg( 'worker_search' ) ) . '">Clear (show current Painters/Helpers)</a>';
        }
        echo '</form>';
        echo '</div>';
        echo '</div></details>';
        // ---- Crews (2026-07-19, v1.0.105) ---------------------------------
        // Named, colored groups of the workers above. Picking a crew on the
        // job form checks its members in and stamps the job's crew_id (see
        // manage-jobs.php); the calendar can filter by crew and Profits rolls
        // up per crew. CRUD handlers ran at the top of this function so the
        // list below always reflects this request's change. Crew data is
        // fetched before the summary renders so the count meta is right.
        $crew_people = get_users( [
            'role__in' => [$role_slug, 'administrator'],
            'orderby'  => 'display_name',
        ] );
        $all_crews = ( function_exists( 'paintops_get_crews' ) ? paintops_get_crews( true ) : [] );
        echo wp_kses( '<details class="paintops-settings-section"' . (( $acc_crews_open ? ' open' : '' )) . '><summary>Crews' . paintops_settings_summary_right( esc_html( (string) count( $all_crews ) ) . (( count( $all_crews ) === 1 ? ' crew' : ' crews' )) ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description">Group your painters into crews (e.g. "Crew A", "Interior crew"). On a job, picking a crew fills in its members with one tap, and the Calendar and Profits pages can filter and report by crew. You can still adjust individual workers on any job after picking a crew.</p>';
        $render_crew_form = function ( $crew ) use($crew_people) {
            $cid = ( $crew ? (int) $crew->id : 0 );
            $members = ( $crew ? paintops_crew_member_ids( $crew ) : [] );
            echo '<form method="post" style="border:1px solid #ccd0d4;border-radius:8px;padding:14px 16px;margin:0 0 12px;background:#fff;max-width:720px;">';
            wp_nonce_field( 'paintops_crew_action' );
            echo '<input type="hidden" name="crew_id" value="' . (int) $cid . '">';
            echo '<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:10px;">';
            echo '<input type="text" name="crew_name" value="' . esc_attr( $crew->name ?? '' ) . '" placeholder="Crew name" required style="flex:1 1 200px;">';
            echo '<label style="display:flex;align-items:center;gap:6px;">Color <input type="color" name="crew_color" value="' . esc_attr( paintops_sanitize_crew_color( (string) ($crew->color ?? '#4682B4') ) ) . '"></label>';
            if ( $cid > 0 ) {
                echo '<label style="display:flex;align-items:center;gap:6px;"><input type="checkbox" name="crew_active" value="1"' . checked( (int) ($crew->active ?? 1), 1, false ) . '> Active</label>';
            } else {
                echo '<input type="hidden" name="crew_active" value="1">';
            }
            echo '</div>';
            echo '<div style="display:flex;gap:14px;flex-wrap:wrap;margin-bottom:10px;">';
            foreach ( $crew_people as $p ) {
                $checked = ( in_array( (int) $p->ID, $members, true ) ? ' checked' : '' );
                echo wp_kses( '<label style="display:flex;align-items:center;gap:5px;"><input type="checkbox" name="crew_members[]" value="' . (int) $p->ID . '"' . $checked . '> ' . esc_html( $p->display_name ) . '</label>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            }
            echo '</div>';
            echo '<button type="submit" name="paintops_save_crew" value="1" class="button button-primary">' . (( $cid > 0 ? 'Save Crew' : 'Add Crew' )) . '</button> ';
            if ( $cid > 0 ) {
                echo wp_kses( '<button type="submit" name="paintops_delete_crew" value="' . (int) $cid . '" class="button" onclick="return confirm(\'Delete this crew? Jobs keep their workers; only the crew label goes away.\');">Delete</button>', paintops_kses_allowed_html(), paintops_kses_protocols() );
            }
            echo '</form>';
        };
        foreach ( $all_crews as $crew_row ) {
            $render_crew_form( $crew_row );
        }
        echo '<h3 style="margin-top:18px;">Add a crew</h3>';
        $render_crew_form( null );
        echo '</div></details>';
        // ---- Team settings (Time Clock + Login Duration). These were plain
        // boxed sections by Mike's 2026-07-22 call; folded into the shared
        // accordion look 2026-07-23 when the whole page went accordion. ----
        $timeclock_on = paintops_timeclock_enabled();
        echo wp_kses( '<details class="paintops-settings-section"' . (( $acc_tc_open ? ' open' : '' )) . '><summary>Time Clock' . paintops_settings_summary_right( ( $timeclock_on ? 'On' : 'Off' ) ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description" style="margin:0 0 10px;">The Time Clock lets staff and assigned Painters/Helpers Check In when they arrive at a job and Check Out when they leave, logging hours to the job automatically (it also works offline and syncs when back online).</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_timeclock_action' );
        echo '<p style="margin:0 0 8px;"><label><input type="checkbox" name="paintops_timeclock_enabled" value="1"' . checked( $timeclock_on, true, false ) . '> Enable the per-job Time Clock (Check In / Check Out)</label></p>';
        echo '<p class="description" style="margin:0 0 10px;">When off, the Time Clock box is hidden from every job and no new check-ins can be recorded. Hours already logged stay put, and staff can still enter or correct hours by hand in Financials &amp; Profitability.</p>';
        echo '<p style="margin:0;"><button type="submit" name="paintops_save_timeclock" value="1" class="button button-primary">Save</button></p>';
        echo '</form>';
        echo '</div></details>';
        $login_days = paintops_login_duration_days();
        echo wp_kses( '<details class="paintops-settings-section"' . (( $acc_login_open ? ' open' : '' )) . '><summary>Team Login Duration' . paintops_settings_summary_right( ( $login_days > 0 ? esc_html( $login_days . ' days' ) : 'WP default' ) ) . '</summary><div class="paintops-settings-body">', paintops_kses_allowed_html(), paintops_kses_protocols() );
        echo '<p class="description" style="margin:0 0 10px;">How long staff and Painter/Helpers stay logged in before WordPress asks them to sign in again. WordPress normally logs people out after 2 days (14 with "Remember Me"), which is a nuisance for a crew pulling up job details on their phones. This applies to each person the next time they log in - it doesn\'t extend sessions that already exist. Customer-facing quote links are unaffected (those never require a login).</p>';
        echo '<form method="post">';
        wp_nonce_field( 'paintops_save_login_duration_action' );
        echo '<p style="margin:0 0 8px;"><label for="paintops_login_duration_days"><strong>Stay logged in for</strong></label> ';
        echo '<select id="paintops_login_duration_days" name="paintops_login_duration_days">';
        foreach ( paintops_login_duration_choices() as $choice ) {
            $label = ( $choice > 0 ? $choice . ' days' : 'WordPress default (2 days / 14 with Remember Me)' );
            echo '<option value="' . esc_attr( (string) $choice ) . '"' . selected( $login_days, $choice, false ) . '>' . esc_html( $label ) . '</option>';
        }
        echo '</select></p>';
        echo '<p class="description" style="margin:0 0 10px;">Longer is more convenient but means a lost or stolen phone stays logged in longer. If a device goes missing, change that team member\'s password - that immediately logs them out everywhere.</p>';
        echo '<p style="margin:0;"><button type="submit" name="paintops_save_login_duration" value="1" class="button button-primary">Save</button></p>';
        echo '</form>';
        echo '</div></details>';
        echo '</div>';
    }

    /**
     * Redirect the "PaintOps" / "Dashboard" wp-admin menu items straight to
     * the real app page. paintops_render_admin_page() is left in place as a
     * fallback (it only ever actually renders for a user who fails the
     * capability check below, since that check also wp_die()s in the render
     * function itself).
     */
    function paintops_redirect_dashboard_to_app() : void {
        if ( !paintops_user_can_manage() ) {
            return;
        }
        // While Freemius is still waiting for its FIRST connection, its
        // connect screen renders on THIS exact admin page (menu slug
        // 'paintops'); redirecting away meant the opt-in could never be seen
        // (hit live on Coastal Standard, 2026-07-20). But hold the redirect
        // ONLY until the site is connected in any form: on a premium build
        // running free/lapsed, raw is_activation_mode() stays true forever
        // and re-landed every Dashboard visit on the license screen
        // (2026-07-21). See paintops_fs_needs_connect().
        if ( paintops_fs_needs_connect() ) {
            return;
        }
        wp_safe_redirect( paintops_app_url() );
        exit;
    }

    add_action( 'admin_menu', 'paintops_register_admin_menu' );
    register_activation_hook( __FILE__, 'paintops_install' );
    register_deactivation_hook( __FILE__, 'paintops_deactivate' );
    function paintops_maybe_install_or_upgrade() : void {
        if ( get_option( 'paintops_db_version' ) !== PAINTOPS_VERSION ) {
            paintops_install();
        }
    }

    add_action( 'admin_init', 'paintops_maybe_install_or_upgrade' );
    add_action( 'admin_init', 'paintops_ensure_app_page', 20 );
    // The PaintOps app itself is typically embedded on a front-end page via
    // shortcode (see all the "if (!is_admin()) { get_footer(); exit; }"
    // checks throughout includes/manage-jobs.php), not loaded inside
    // /wp-admin/, so admin_init alone can miss a pending version-bump
    // migration for a long time on a site whose owner never happens to
    // visit an actual wp-admin screen. Also running this check on the
    // public 'init' hook means a new table (or column) a version bump adds
    // gets created the moment anyone next loads the app on the front end,
    // not just whenever wp-admin happens to be visited. get_option() below
    // makes this a cheap no-op on every request once versions match.
    add_action( 'init', 'paintops_maybe_install_or_upgrade' );
    add_action( 'plugins_loaded', function () {
        if ( file_exists( PAINTOPS_PLUGIN_DIR . 'includes/pro-services.php' ) ) {
            require_once PAINTOPS_PLUGIN_DIR . 'includes/pro-services.php';
        }
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-financials-addon.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/pro-expense-reports.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-profit-hour-rankings.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-profit-by-classification.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/crew-profit.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/trade-calculators.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-pipeline.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-rows.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/todays-crew-widget.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/dashboard-alerts.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/quote-stats-widget.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/customer-payments.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/customers.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/calendar-feed.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-paint-colors.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-photos.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-invoices.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/app-themes.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-followups.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-checkin.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-view.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/manage-jobs.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/job-manager-notifications.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/resource-links.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/ai-features.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/pwa.php';
        require_once PAINTOPS_PLUGIN_DIR . 'includes/pwa-timeclock.php';
    } );
}
// end: free/premium auto-swap "else" branch (see top of file)