<?php
// Plugin Check / PHPCS: category-level exclusions with reasons.
// Line-level findings in this file are handled individually below.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- PaintOps keeps its records in its own custom tables (built by paintops_table()); $wpdb is the supported API for plugin-owned tables, there is no core CRUD alternative, and results are request-scoped.
// phpcs:disable WordPress.DateTime.RestrictedFunctions.date_date -- date() here is deliberately paired with current_time() timestamps: crews are scheduled on the site's local calendar day, and gmdate() would shift jobs across midnight UTC.
/**
 * PaintOps - Job Photos & Expense Receipts
 *
 * Two related but separate photo buckets, sharing one table
 * (cs_job_photos) and one upload/resize pipeline:
 *
 *  - "photo": general job photos (before/after, progress shots).
 *    Capped per job by an admin-configurable limit (default 15, see
 *    paintops_job_photo_limit() in paintops.php, editable under
 *    Business Settings > Company Profile). Not gated behind Pro,
 *    this is operational job documentation, not a profitability
 *    feature, same reasoning as Paint Colors (see job-paint-colors.php).
 *
 *  - "receipt": a photo of a receipt attached to one specific expense
 *    row in cs_job_expenses. Shown and uploaded from inside the Job
 *    Financials box (see job-financials-addon.php), which is already a
 *    Pro-only feature, so no separate Pro check is needed here.
 *    paintops_save_job_financials_from_post() now upserts expense rows keyed
 *    by a stable expense_id (rather than the old delete-then-reinsert),
 *    so a receipt tied to one line item survives every later Financials
 *    save, and gets cleaned up (see paintops_delete_job_photos_for_expense())
 *    if that line item is ever removed.
 *
 * Every uploaded image is resized/recompressed in place before it ever
 * becomes a WordPress media attachment (see
 * paintops_resize_and_compress_image()), a modern phone photo often
 * runs 8-15MB; capping the longest side and re-saving as JPEG brings
 * that down to roughly 150-300KB with no visible quality loss on a
 * job-site screen. That single step is what keeps 15+ photos per job
 * from ever becoming a real storage concern, so there's no need for
 * any kind of auto-delete-old-photos logic, manual delete (see
 * paintops_delete_job_photo()) is all that's needed.
 */

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

/**
 * Longest side (in pixels) a job photo or receipt is resized down to on
 * upload. 1600px is plenty for a phone/tablet screen and for printing a
 * profit report, there's no reason to keep camera-original resolution
 * (often 3000-4000px+) for a documentation photo.
 */
function paintops_job_photo_max_dimension(): int {
    return 1600;
}

/**
 * JPEG re-encode quality (0-100) applied on upload. 82 is a well-tested
 * sweet spot, visually lossless for a documentation/receipt photo,
 * while cutting file size dramatically versus a camera-original JPEG
 * (which is often saved at or near 100).
 */
function paintops_job_photo_jpeg_quality(): int {
    return 82;
}

/**
 * Resize + recompress an image file in place. Uses WordPress's own image
 * editor abstraction (Imagick if available, GD fallback), so this needs
 * no new dependency and behaves the same as any other WP image resize.
 * Safe to call on any image already saved to disk, e.g. right after
 * wp_handle_upload(), before it's registered as an attachment.
 *
 * Returns true on success. Deliberately fails soft (returns false, but
 * leaves the original file in place) rather than blocking the upload,
 * an un-shrunk photo saved is much better than a failed upload just
 * because, say, the server has neither Imagick nor GD's resize support.
 */
function paintops_resize_and_compress_image(string $file_path): bool {
    if (!function_exists('wp_get_image_editor') || !file_exists($file_path)) {
        return false;
    }

    $editor = wp_get_image_editor($file_path);
    if (is_wp_error($editor)) {
        return false;
    }

    $size = $editor->get_size();
    $max_dim = paintops_job_photo_max_dimension();

    if (is_array($size) && max((int) $size['width'], (int) $size['height']) > $max_dim) {
        // false = fit within a max_dim x max_dim box, keep aspect ratio,
        // don't crop, this caps the longest side regardless of
        // whether the photo is portrait or landscape.
        $resized = $editor->resize($max_dim, $max_dim, false);
        if (is_wp_error($resized)) {
            return false;
        }
    }

    if (method_exists($editor, 'set_quality')) {
        $editor->set_quality(paintops_job_photo_jpeg_quality());
    }

    $saved = $editor->save($file_path);

    return !is_wp_error($saved);
}

/**
 * Read whatever EXIF metadata a photo already had at capture time,
 * specifically the original date/time taken and GPS coordinates, if the
 * phone/camera embedded them. MUST be called on the just-uploaded file
 * BEFORE paintops_resize_and_compress_image() runs: re-saving through
 * GD/Imagick strips EXIF (GD always does; Imagick's behavior varies by
 * build), so this is the only point where that data is still there to
 * read.
 *
 * Returns ['taken_at' => 'Y-m-d H:i:s'|null, 'gps_lat' => float|null,
 * 'gps_lng' => float|null]. All null if the exif extension isn't
 * available (not every host has it compiled in), the file has no EXIF
 * block at all (screenshots, downloaded/forwarded images, many desktop
 * photo edits), or GPS specifically was stripped, which is the
 * default on iOS unless the user has explicitly allowed location for
 * the camera app, and off entirely on plenty of Android phones too. A
 * missing result here is the normal case, not a bug.
 */
function paintops_extract_photo_exif(string $file_path): array {
    $result = ['taken_at' => null, 'gps_lat' => null, 'gps_lng' => null];

    if (!function_exists('exif_read_data') || !file_exists($file_path)) {
        return $result;
    }

    // @-suppressed: exif_read_data() emits a PHP warning (not just a
    // false return) on a file it can't parse, e.g. a JPEG with a
    // malformed/truncated EXIF segment, which happens in the wild often
    // enough that this shouldn't ever surface as a visible error on an
    // otherwise-successful photo upload.
    $exif = @exif_read_data($file_path, 'EXIF,GPS', true);
    if (!is_array($exif)) {
        return $result;
    }

    $dateTaken = $exif['EXIF']['DateTimeOriginal'] ?? $exif['IFD0']['DateTimeOriginal'] ?? null;
    if (is_string($dateTaken) && $dateTaken !== '') {
        // EXIF dates are "YYYY:MM:DD HH:MM:SS", swap the date
        // separators so strtotime() parses it reliably instead of
        // guessing at an ambiguous format. EXIF carries no timezone at
        // all, so this is stored exactly as the camera's clock read it
        // at the moment of capture, no UTC/site-timezone conversion,
        // same "just the literal date/time string" treatment every
        // other stored timestamp in this plugin already gets.
        $normalized = preg_replace('/^(\d{4}):(\d{2}):(\d{2})/', '$1-$2-$3', $dateTaken);
        $timestamp = strtotime((string) $normalized);
        if ($timestamp !== false) {
            $result['taken_at'] = date('Y-m-d H:i:s', $timestamp);
        }
    }

    $gps = $exif['GPS'] ?? null;
    if (is_array($gps) && isset($gps['GPSLatitude'], $gps['GPSLatitudeRef'], $gps['GPSLongitude'], $gps['GPSLongitudeRef'])) {
        $lat = paintops_exif_gps_to_decimal($gps['GPSLatitude'], (string) $gps['GPSLatitudeRef']);
        $lng = paintops_exif_gps_to_decimal($gps['GPSLongitude'], (string) $gps['GPSLongitudeRef']);
        if ($lat !== null && $lng !== null) {
            $result['gps_lat'] = $lat;
            $result['gps_lng'] = $lng;
        }
    }

    return $result;
}

/**
 * Converts one EXIF GPS coordinate (an array of 3 "num/den" degree/
 * minute/second fraction strings, as exif_read_data() returns them)
 * into a single signed decimal degree value. $ref is the accompanying
 * N/S or E/W reference, S and W flip the sign negative.
 */
function paintops_exif_gps_to_decimal(array $dms, string $ref): ?float {
    if (count($dms) < 3) {
        return null;
    }

    $toFloat = static function ($part): float {
        if (strpos((string) $part, '/') !== false) {
            [$num, $den] = array_pad(explode('/', (string) $part), 2, '1');
            $den = (float) $den;
            return $den != 0.0 ? ((float) $num) / $den : 0.0;
        }
        return (float) $part;
    };

    $degrees = $toFloat($dms[0]);
    $minutes = $toFloat($dms[1]);
    $seconds = $toFloat($dms[2]);

    $decimal = $degrees + ($minutes / 60) + ($seconds / 3600);

    $ref = strtoupper(trim($ref));
    if ($ref === 'S' || $ref === 'W') {
        $decimal *= -1;
    }

    return $decimal;
}

/**
 * Client-side image compression before upload, shrinks a chosen photo
 * right in the browser (canvas resample + JPEG re-encode, targeting the
 * same ~1600px longest side / ~82% quality as
 * paintops_resize_and_compress_image()) before the file is ever sent
 * over the wire. This is what actually fixes "the expense saved but the
 * receipt didn't": our own resize step runs server-side, AFTER
 * wp_handle_upload(), so a multi-megabyte camera-original photo has
 * to survive the host's upload_max_filesize/post_max_size first. Those
 * are PHP_INI_PERDIR settings the plugin cannot raise at runtime, and
 * many hosts default them low enough that a real phone photo gets
 * silently rejected (the rest of the form's fields save fine, since a
 * per-file size violation doesn't affect other POST data, which is
 * exactly the symptom: the expense line item saves, the receipt image
 * doesn't). Compressing before upload means the file that actually
 * gets sent is already small, comfortably under virtually any host's
 * limit, and it uploads noticeably faster on a weak job-site connection.
 *
 * Any <input type="file" class="paintops-photo-input"> gets this
 * automatically. Uses only native browser APIs (FileReader, canvas,
 * DataTransfer), if the browser doesn't support one of them (very
 * old browsers only), it silently leaves the original file alone rather
 * than blocking the upload, so this never makes things worse.
 *
 * Printed once per page load regardless of how many boxes call it.
 */
function paintops_echo_photo_compress_script(): void {
    static $printed = false;
    if ($printed) {
        return;
    }
    $printed = true;
    paintops_enqueue_script('job-photos-compress');
}

/**
 * "Take Photo" / "Choose Photo" picker widget markup + shared wiring
 * script. Plain `capture="environment"` on a single file input (the
 * previous approach) turned out to be too restrictive on some Android
 * versions, current Chrome/Android treats a bare `accept="image/*"`
 * input as a trigger for the OS-level Photo Picker, which is gallery-only
 * by design and has no way to launch the camera from it, while adding
 * `capture="environment"` back skips the gallery entirely and always
 * opens the camera. Neither single-input approach can offer both, so
 * this renders two hidden file inputs (one with capture, one without)
 * behind two buttons, and syncs whichever one gets used into the real
 * input everything else (compression, PHP $_FILES) already expects.
 *
 * $name/$id/$required become the real, form-submitted <input>; leave
 * $name empty for a JS-only consumer (see the AI Photo Estimate box in
 * estimates.php, which reads the file directly rather than submitting a
 * form). Multiple pickers can coexist on one page, including ones added
 * later via JS (e.g. a new expense row), the wiring script below uses
 * event delegation scoped by class, not IDs, so it doesn't need to know
 * about a picker in advance.
 *
 * $hidden hides the whole widget (buttons included) while keeping the
 * real <input> in the DOM, needed by paintops_render_job_financial_box(),
 * where expense_receipt_file[] must stay present and in-position for
 * every row even after a receipt's already attached (removing it would
 * shift every later row's file out of index alignment).
 */
function paintops_render_photo_picker_input(string $name, string $id = '', bool $required = false, bool $hidden = false, bool $autosubmit = false, bool $allow_pdf = false): void {
    $accept_attr = $allow_pdf ? 'image/*,application/pdf' : 'image/*';
    $id_attr = $id !== '' ? ' id="' . esc_attr($id) . '"' : '';
    $name_attr = $name !== '' ? ' name="' . esc_attr($name) . '"' : '';
    $required_attr = $required ? ' required' : '';
    // $autosubmit: once a photo is picked (and compressed), submit the form
    // automatically, no separate Upload button tap. Opt-in per picker so the
    // note composer / receipt forms that need other fields first can leave it
    // off. The compression script reads this flag (see paintops_photo_maybe
    // _auto_submit) and only submits after the resize completes.
    $autosubmit_attr = $autosubmit ? ' data-pp-autosubmit="1"' : '';
    $wrapper_style = $hidden
        ? 'display:none;'
        : 'display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap;';
    ?>
    <span class="paintops-photo-picker" style="<?php echo esc_attr($wrapper_style); ?>">
        <input type="file"<?php echo wp_kses($id_attr . $name_attr, paintops_kses_allowed_html(), paintops_kses_protocols()); ?> class="paintops-photo-input pp-real-input" accept="<?php echo esc_attr($accept_attr); ?>" style="display:none;"<?php echo wp_kses($required_attr . $autosubmit_attr, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>>
        <input type="file" class="pp-camera-input" accept="image/*" capture="environment" style="display:none;">
        <input type="file" class="pp-library-input" accept="<?php echo esc_attr($accept_attr); ?>" style="display:none;">
        <button type="button" class="paintops-btn paintops-btn-secondary pp-camera-btn">Take Photo</button>
        <button type="button" class="paintops-btn paintops-btn-secondary pp-library-btn"><?php echo esc_attr($allow_pdf ? 'Choose Photo/PDF' : 'Choose Photo'); ?></button>
        <span class="pp-filename" style="color:#777;font-size:12px;"></span>
    </span>
    <?php
}

/**
 * Wiring script for paintops_render_photo_picker_input(), printed once
 * per page regardless of how many pickers (present at load or added
 * later via JS) end up on it.
 */
function paintops_echo_photo_picker_script(): void {
    static $printed = false;
    if ($printed) {
        return;
    }
    $printed = true;
    paintops_enqueue_script('job-photos-picker');
}

/**
 * Small trigger button for one unpaired "Before" photo, shown under it
 * on the Job Photos page. Clicking it opens the shared alignment-overlay
 * camera modal (see paintops_echo_before_after_capture_script()) with
 * this photo's id/thumbnail wired in as the ghost reference, reusing
 * one modal for every Before photo on the page rather than printing a
 * separate camera UI per photo, which would mean requesting the camera
 * stream repeatedly for no reason.
 */
function paintops_render_before_after_capture_button(int $beforeId, string $beforeUrl): void {
    ?>
    <button type="button" class="paintops-btn paintops-btn-secondary pp-take-after-btn" data-before-id="<?php echo esc_attr((string) $beforeId); ?>" data-before-url="<?php echo esc_url($beforeUrl); ?>" style="width:100%;margin-top:6px;">Take Matching After Photo</button>
    <?php
}

/**
 * Shared modal + wiring script for capturing an aligned "After" photo
 * against a "Before" photo's ghost overlay, the QuoteIQ-style feature
 * Mike asked for. Printed once per page (like the other echo_*_script()
 * helpers here), and opened by any .pp-take-after-btn on the page (see
 * paintops_render_before_after_capture_button()).
 *
 * How it works: requests the phone/tablet's rear camera via
 * getUserMedia() and shows the live feed in a <video>, with the chosen
 * Before photo laid on top at partial opacity (adjustable via a slider)
 * so the person taking the shot can match the framing before snapping.
 * "Capture" draws the current video frame to a hidden <canvas> (the
 * ghost overlay is never baked into the actual photo, it's purely a
 * live on-screen guide), turns that into a JPEG blob, and auto-submits
 * it through the exact same paintops_upload_after_photo POST handler a
 * plain file upload would use.
 *
 * getUserMedia requires a secure context (HTTPS) and an explicit
 * permission grant, and isn't available at all in some embedded/older
 * mobile browser contexts, if it's missing, denied, or errors for any
 * reason, this quietly falls back to a plain capture="environment" file
 * input (still tied to the same Before photo/action, just without the
 * live ghost overlay) rather than leaving the person stuck.
 */
function paintops_echo_before_after_capture_script(int $job_id): void {
    static $printed = false;
    if ($printed) {
        return;
    }
    $printed = true;
    ?>
    <div class="pp-after-modal-overlay" style="display:none;position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.85);overflow-y:auto;">
        <div style="position:relative;width:100%;min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:16px 0;box-sizing:border-box;">
            <div class="pp-after-stage" style="position:relative;max-width:100%;max-height:65vh;">
                <video class="pp-after-video" playsinline autoplay muted style="max-width:100%;max-height:65vh;display:block;"></video>
                <img class="pp-after-ghost" alt="Before photo (ghost overlay)" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;opacity:0.4;pointer-events:none;">
            </div>
            <canvas class="pp-after-canvas" style="display:none;"></canvas>

            <div style="width:100%;max-width:400px;padding:12px;color:#fff;text-align:center;">
                <label style="font-size:12px;display:block;margin-bottom:4px;">Ghost opacity</label>
                <input type="range" class="pp-after-opacity" min="0" max="100" value="40" style="width:100%;">
            </div>

            <div style="display:flex;gap:12px;padding:8px 12px 16px;">
                <button type="button" class="paintops-btn paintops-btn-secondary pp-after-cancel">Cancel</button>
                <button type="button" class="paintops-btn paintops-btn-primary pp-after-capture">Capture</button>
            </div>

            <p class="pp-after-fallback-note" style="display:none;color:#fff;font-size:12px;max-width:320px;text-align:center;padding:0 12px 16px;">
                Camera preview isn't available here, use your device's camera/photo picker below instead. Your Before photo is shown above for reference while you line up the shot.
            </p>
            <img class="pp-after-fallback-ghost" alt="Before photo" style="display:none;max-width:260px;max-height:260px;border-radius:6px;border:1px solid #666;margin-bottom:10px;">

            <form method="post" enctype="multipart/form-data" class="pp-after-form" style="text-align:center;">
                <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                <input type="hidden" name="paintops_upload_after_photo" class="pp-after-before-id" value="">
                <input type="file" name="job_after_photo_file" class="pp-after-fallback-input" accept="image/*" capture="environment" style="display:none;">
            </form>
        </div>
    </div>
    <?php
    paintops_enqueue_script('job-photos-capture');
}

/**
 * Trigger button for one Before/After pair, opens the shared
 * drag-to-reveal comparison slider (see
 * paintops_echo_before_after_compare_script()) with this pair's two full
 * -resolution images wired in.
 */
function paintops_render_compare_trigger_button(string $beforeUrl, string $afterUrl): void {
    ?>
    <button type="button" class="paintops-btn paintops-btn-secondary pp-view-compare-btn" data-before-url="<?php echo esc_url($beforeUrl); ?>" data-after-url="<?php echo esc_url($afterUrl); ?>" style="width:100%;">View Comparison</button>
    <?php
}

/**
 * Shared modal + wiring script for the Before/After comparison slider,
 * the other half of the QuoteIQ-style feature Mike asked for (the
 * camera alignment overlay above helps CAPTURE a well-matched After
 * shot; this is what lets him and customers actually SEE the two side
 * by side afterward). Printed once per page; opened by any
 * .pp-view-compare-btn (see paintops_render_compare_trigger_button()).
 *
 * Pure display, no camera, no upload, no server round-trip. A plain
 * <input type="range"> drives a CSS clip-path on the After image
 * (stacked exactly on top of the Before image, which sets the frame's
 * size/aspect ratio) so dragging it left/right reveals more of one photo
 * or the other. Range inputs are natively touch-draggable on every
 * mobile browser, so this needed no custom pointer-drag handling to
 * work well on a phone.
 */
function paintops_echo_before_after_compare_script(): void {
    static $printed = false;
    if ($printed) {
        return;
    }
    $printed = true;
    ?>
    <div class="pp-compare-modal-overlay" style="display:none;position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.9);overflow-y:auto;">
        <div style="max-width:700px;margin:0 auto;padding:20px 16px;">
            <div class="pp-compare-stage" style="position:relative;width:100%;overflow:hidden;border-radius:6px;background:#111;">
                <img class="pp-compare-before" alt="Before" style="display:block;width:100%;height:auto;">
                <img class="pp-compare-after" alt="After" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;clip-path:inset(0 50% 0 0);">
                <div class="pp-compare-divider" style="position:absolute;top:0;bottom:0;left:50%;width:2px;background:#fff;pointer-events:none;"></div>
            </div>

            <input type="range" class="pp-compare-slider" min="0" max="100" value="50" style="width:100%;margin-top:14px;">
            <div style="display:flex;justify-content:space-between;color:#fff;font-size:12px;margin-top:2px;">
                <span>Before</span><span>After</span>
            </div>

            <div style="text-align:center;margin-top:16px;">
                <button type="button" class="paintops-btn paintops-btn-secondary pp-compare-close">Close</button>
            </div>
        </div>
    </div>
    <?php
    paintops_enqueue_script('job-photos-compare');
}

/**
 * Photos or receipts attached to a job. $context is 'photo' or
 * 'receipt'; pass $expense_id to scope receipts to one specific expense
 * row (leave null to get every receipt on the job, across all expenses).
 *
 * Each row includes a ready-to-use 'url' (medium-size, for a thumbnail
 * grid) and 'full_url' (original resized upload, for a lightbox/print),
 * plus display-ready extras built from the metadata columns added
 * alongside 'notes': 'uploaded_by_name' (WP display name, falling back
 * to "Someone" if the uploading account was later deleted),
 * 'uploaded_at_display' and 'taken_at_display' (both human-readable, in
 * the site's configured timezone), and 'map_url' (a plain Google Maps
 * link, only set when the photo actually had GPS EXIF, most won't,
 * see paintops_extract_photo_exif()).
 */
function paintops_get_job_photos(int $job_id, string $context = 'photo', ?int $expense_id = null): array {
    global $wpdb;

    if ($job_id <= 0) {
        return [];
    }

    $table = paintops_table('job_photos');
    $sql = "SELECT id, job_id, expense_id, context, attachment_id, uploaded_by, uploaded_at, notes, taken_at, gps_lat, gps_lng, photo_role, paired_photo_id FROM {$table} WHERE job_id = %d AND context = %s";
    $params = [$job_id, $context];

    if ($expense_id !== null) {
        $sql .= " AND expense_id = %d";
        $params[] = $expense_id;
    }

    $sql .= " ORDER BY id ASC";

    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is assembled above from fixed literal clauses with %i/%d placeholders bound via $params
    $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) ?: [];

    foreach ($rows as &$row) {
        $attachment_id = (int) $row['attachment_id'];
        $thumb = wp_get_attachment_image_url($attachment_id, 'medium');
        $full = wp_get_attachment_image_url($attachment_id, 'full');
        $row['url'] = $thumb ?: $full ?: '';
        $row['full_url'] = $full ?: $row['url'];

        // PDF receipts (2026-07-22): no image sizes exist, fall back to
        // the file itself so the "View" links open the PDF.
        $row['is_pdf'] = (get_post_mime_type($attachment_id) === 'application/pdf');
        if ($row['url'] === '' && $row['is_pdf']) {
            $pdf_url = (string) wp_get_attachment_url($attachment_id);
            $row['url'] = $pdf_url;
            $row['full_url'] = $pdf_url;
        }

        $uploader = (int) $row['uploaded_by'] > 0 ? get_userdata((int) $row['uploaded_by']) : false;
        $row['uploaded_by_name'] = $uploader ? $uploader->display_name : 'Someone';

        // Plain date()/strtotime(), matching how every other stored
        // timestamp in this plugin (createdOn, uploaded_at itself
        // before this) is already displayed elsewhere, no GMT/site-
        // timezone conversion layered on top, for consistency.
        $row['uploaded_at_display'] = !empty($row['uploaded_at'])
            ? date('M j, Y g:i a', strtotime((string) $row['uploaded_at']))
            : '';

        $row['taken_at_display'] = !empty($row['taken_at'])
            ? date('M j, Y g:i a', strtotime((string) $row['taken_at']))
            : '';

        $row['map_url'] = ($row['gps_lat'] !== null && $row['gps_lng'] !== null && $row['gps_lat'] !== '' && $row['gps_lng'] !== '')
            ? 'https://maps.google.com/?q=' . rawurlencode($row['gps_lat'] . ',' . $row['gps_lng'])
            : '';
    }
    unset($row);

    return $rows;
}

/**
 * The Job Notes feed: every note + photo row on a job (context 'note' or
 * 'photo'), newest first, in the same enriched shape paintops_get_job_photos()
 * returns (url/full_url resolve to '' for a text-only note). Receipts (context
 * 'receipt') are deliberately excluded, those live on the expense side. This
 * is what the Job Notes page renders as the chronological log.
 */
function paintops_get_job_feed(int $job_id): array {
    global $wpdb;

    if ($job_id <= 0) {
        return [];
    }

    $table = paintops_table('job_photos');
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT id, job_id, expense_id, context, attachment_id, uploaded_by, uploaded_at, notes, taken_at, gps_lat, gps_lng, photo_role, paired_photo_id
         FROM %i
         WHERE job_id = %d AND context IN ('note','photo')
         ORDER BY uploaded_at DESC, id DESC", $table, $job_id), ARRAY_A) ?: [];

    foreach ($rows as &$row) {
        $attachment_id = (int) $row['attachment_id'];
        $thumb = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'medium') : false;
        $full  = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'full') : false;
        $row['url']      = $thumb ?: ($full ?: '');
        $row['full_url'] = $full ?: $row['url'];

        $uploader = (int) $row['uploaded_by'] > 0 ? get_userdata((int) $row['uploaded_by']) : false;
        $row['uploaded_by_name'] = $uploader ? $uploader->display_name : 'Someone';

        $row['uploaded_at_display'] = !empty($row['uploaded_at'])
            ? date('M j, Y g:i a', strtotime((string) $row['uploaded_at']))
            : '';
        $row['taken_at_display'] = !empty($row['taken_at'])
            ? date('M j, Y g:i a', strtotime((string) $row['taken_at']))
            : '';
        $row['map_url'] = ($row['gps_lat'] !== null && $row['gps_lng'] !== null && $row['gps_lat'] !== '' && $row['gps_lng'] !== '')
            ? 'https://maps.google.com/?q=' . rawurlencode($row['gps_lat'] . ',' . $row['gps_lng'])
            : '';
    }
    unset($row);

    return $rows;
}

/**
 * Add a text-only note to a job's log (a cs_job_photos row with context 'note'
 * and no attachment). Photo notes go through paintops_handle_job_photo_upload()
 * instead (context 'photo', so they stay eligible for Before/After pairing);
 * this is only the no-image path. Returns the new row id or a WP_Error.
 *
 * @return int|WP_Error
 */
function paintops_add_job_text_note(int $job_id, string $notes) {
    global $wpdb;

    if ($job_id <= 0) {
        return new WP_Error('paintops_bad_job', 'Invalid job.');
    }
    $notes = trim(wp_strip_all_tags($notes));
    if ($notes === '') {
        return new WP_Error('paintops_empty_note', 'Add a note or a photo.');
    }

    $ok = $wpdb->insert(
        paintops_table('job_photos'),
        [
            'job_id'      => $job_id,
            'context'     => 'note',
            'attachment_id' => null,
            'uploaded_by' => get_current_user_id(),
            'notes'       => $notes,
        ],
        ['%d', '%s', '%d', '%d', '%s']
    );

    if ($ok === false) {
        return new WP_Error('paintops_note_failed', 'Could not save that note, please try again.');
    }
    return (int) $wpdb->insert_id;
}

/**
 * Job Notes page, the mobile-first replacement for the old photo gallery.
 * One prominent composer at the top (a note plus an OPTIONAL photo) so on a
 * phone there's exactly one obvious "Add" action, then a newest-first feed of
 * every note + photo on the job (each stamped with date/time + who), then the
 * Before/After tools in their own section below. Reuses the same upload,
 * delete, edit-note, and pairing backend as the old gallery, only the
 * presentation changed.
 */
function paintops_render_job_notes_box(int $job_id): void {
    if (!paintops_user_can_manage_job_photos($job_id)) {
        return;
    }

    $feed           = paintops_get_job_feed($job_id);
    $photo_count    = paintops_count_job_photos($job_id, 'photo');
    $limit          = paintops_job_photo_limit();
    $at_photo_limit = $photo_count >= $limit;

    // Which accordion section to keep open after a synchronous POST on this
    // page, so deleting a photo (or saving a note, or a before/after action)
    // doesn't bounce the user back to the default "New Photo or Note" section. The
    // three sections share name="pv-note-acc" (an exclusive accordion), so
    // exactly one gets `open`, chosen by which form was just submitted.
    $pv_active_acc = 'note';
    if (isset($_POST['paintops_delete_job_photo']) || isset($_POST['paintops_save_job_photo_notes'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing dispatch only, each branch handler verifies its own nonce before acting
        $pv_active_acc = 'notes';
    } elseif (isset($_POST['paintops_upload_before_photo']) || isset($_POST['paintops_upload_after_photo']) || isset($_POST['paintops_pair_existing_photos']) || isset($_POST['paintops_unpair_job_photo'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing dispatch only, each branch handler verifies its own nonce before acting
        $pv_active_acc = 'ba';
    }
    ?>
    <?php paintops_enqueue_style('job-photos-notes'); ?>

    <details class="pv-acc" name="pv-note-acc"<?php echo esc_attr($pv_active_acc === 'note' ? ' open' : ''); ?>>
        <summary>New Photo or Note</summary>
        <div class="pv-acc-body">
        <p style="color:#777;font-size:13px;margin-top:0;">Log what happened on the job: a quick note, a photo, or both. Everything is stamped with the time and who added it.</p>
        <?php if ($at_photo_limit): ?>
            <p style="color:#b00;font-size:13px;">This job has hit its <?php echo esc_html((string) $limit); ?>-photo limit, so new entries can be text-only until you remove a photo below (or raise the limit under PaintOps &gt; Business &gt; Company Profile).</p>
        <?php endif; ?>
        <form method="post" enctype="multipart/form-data" class="pv-note-composer">
            <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
            <textarea name="job_note_text" rows="3" placeholder="What happened on the job? (e.g. &quot;Second coat done on the north wall. Customer wants the trim a shade darker.&quot;)"></textarea>
            <?php if (!$at_photo_limit): ?>
            <div style="margin-top:10px;">
                <?php paintops_render_photo_picker_input('job_note_photo', '', false, false, true); ?>
            </div>
            <?php endif; ?>
            <button type="submit" name="paintops_add_job_note" value="1" class="paintops-btn paintops-btn-primary" style="margin-top:10px;">Add to Log</button>
        </form>
        </div>
    </details>

    <details class="pv-acc" name="pv-note-acc"<?php echo esc_attr($pv_active_acc === 'notes' ? ' open' : ''); ?>>
        <summary>Notes &amp; Photos<?php echo esc_attr($feed ? ' (' . count($feed) . ')' : ''); ?></summary>
        <div class="pv-acc-body">
        <?php if ($feed): ?>
        <div class="pv-note-feed">
            <?php foreach ($feed as $item):
                $canEdit  = paintops_can_current_user_delete_upload((int) $item['uploaded_by']);
                $hasPhoto = $item['url'] !== '';
            ?>
            <div class="pv-note">
                <?php if ($hasPhoto): ?>
                <div class="thumb">
                    <a href="<?php echo esc_url($item['full_url']); ?>" target="_blank" rel="noopener noreferrer"><img src="<?php echo esc_url($item['url']); ?>" alt="Job photo"></a>
                </div>
                <?php endif; ?>
                <div class="body">
                    <?php if (!empty($item['photo_role'])): ?>
                        <span style="display:inline-block;margin-bottom:4px;padding:1px 7px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;<?php echo esc_attr($item['photo_role'] === 'before' ? 'background:#eee;color:#555;' : 'background:#dff0d8;color:#3c763d;'); ?>"><?php echo esc_html(ucfirst((string) $item['photo_role'])); ?><?php echo esc_attr(empty($item['paired_photo_id']) ? ' (unpaired)' : ''); ?></span>
                    <?php endif; ?>

                    <?php if ($canEdit): ?>
                        <form method="post" style="margin:0 0 4px;">
                            <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                            <textarea name="job_photo_notes_edit" rows="2" style="width:100%;box-sizing:border-box;font-size:14px;resize:vertical;" placeholder="Add a note..."><?php echo esc_textarea((string) ($item['notes'] ?? '')); ?></textarea>
                            <button type="submit" name="paintops_save_job_photo_notes" value="<?php echo esc_attr((string) $item['id']); ?>" class="button button-small" style="margin-top:3px;">Save note</button>
                        </form>
                    <?php elseif (!empty($item['notes'])): ?>
                        <p class="txt"><?php echo esc_html((string) $item['notes']); ?></p>
                    <?php endif; ?>

                    <div class="meta">
                        <?php echo esc_html($item['uploaded_at_display']); ?> &middot; <?php echo esc_html($item['uploaded_by_name']); ?>
                        <?php if ($item['map_url'] !== ''): ?> &middot; <a href="<?php echo esc_url($item['map_url']); ?>" target="_blank" rel="noopener noreferrer">location</a><?php endif; ?>
                    </div>

                    <?php if ($canEdit): ?>
                    <div class="pv-note-actions" style="margin-top:6px;">
                        <form method="post" onsubmit="return confirm('Delete this entry?');">
                            <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                            <button type="submit" name="paintops_delete_job_photo" value="<?php echo esc_attr((string) $item['id']); ?>" class="button button-small" style="color:#b32d2e;">Delete</button>
                        </form>
                    </div>
                    <?php endif; ?>
                </div>
            </div>
            <?php endforeach; ?>
        </div>
        <?php else: ?>
            <p style="color:var(--po-text-muted,#777);">No notes yet. Add the first one above.</p>
        <?php endif; ?>
        </div>
    </details>

    <details class="pv-acc" name="pv-note-acc"<?php echo esc_attr($pv_active_acc === 'ba' ? ' open' : ''); ?>>
        <summary>Before / After</summary>
        <div class="pv-acc-body">
            <?php paintops_render_before_after_box($job_id, true); ?>
        </div>
    </details>

    <?php
    paintops_echo_photo_compress_script();
    paintops_echo_photo_picker_script();
    paintops_echo_before_after_capture_script($job_id);
    paintops_echo_before_after_compare_script();
}

/**
 * Every unpaired "Before" photo on a job, i.e. one tagged photo_role =
 * 'before' that has no photo_role = 'after' shot linked to it yet. Shown
 * on the Job Photos page as "waiting for its After photo," each with a
 * button to launch the camera alignment overlay (see
 * paintops_echo_before_after_capture_script()) that uses this photo as
 * the ghost reference.
 */
function paintops_get_unpaired_before_photos(int $job_id): array {
    $photos = paintops_get_job_photos($job_id, 'photo');

    return array_values(array_filter($photos, static function ($photo) {
        return ($photo['photo_role'] ?? '') === 'before' && empty($photo['paired_photo_id']);
    }));
}

/**
 * Every photo on the job that ISN'T already part of a Before/After pair
 *, regardless of role, so this includes plain untagged photos (the
 * normal case for anything uploaded before this feature existed) as
 * well as an unpaired 'before'-tagged one. Populates both dropdowns on
 * the "manually pair two photos I already have" control (see
 * paintops_render_before_after_box()), since any two unpaired photos are
 * fair game to link up retroactively.
 */
function paintops_get_unpaired_photos(int $job_id): array {
    $photos = paintops_get_job_photos($job_id, 'photo');

    return array_values(array_filter($photos, static function ($photo) {
        return empty($photo['paired_photo_id']);
    }));
}

/**
 * Every Before/After pair on a job, as ['before' => row, 'after' => row].
 * Only ever returns fully-linked pairs, an unpaired Before (see
 * paintops_get_unpaired_before_photos()) never shows up here. Built from
 * a single paintops_get_job_photos() call rather than a separate query,
 * since every pair's both halves are already 'photo' context rows on
 * the same job.
 */
function paintops_get_before_after_pairs(int $job_id): array {
    $photos = paintops_get_job_photos($job_id, 'photo');

    $byId = [];
    foreach ($photos as $photo) {
        $byId[(int) $photo['id']] = $photo;
    }

    $pairs = [];
    $seen = [];
    foreach ($photos as $photo) {
        $id = (int) $photo['id'];
        if (($photo['photo_role'] ?? '') !== 'before' || empty($photo['paired_photo_id']) || isset($seen[$id])) {
            continue;
        }
        $afterId = (int) $photo['paired_photo_id'];
        if (!isset($byId[$afterId])) {
            continue; // Partner row is gone/on a different job, shouldn't happen, but don't blow up the page if it does.
        }
        $pairs[] = ['before' => $photo, 'after' => $byId[$afterId]];
        $seen[$id] = true;
        $seen[$afterId] = true;
    }

    return $pairs;
}

/**
 * Links two already-uploaded photos as a Before/After pair. Both must
 * already belong to $job_id and be plain, unpaired 'photo'-context rows
 *, used both by the "take a matching After shot right now" flow (see
 * paintops_handle_job_photo_upload()'s $pairWithBeforeId param, which
 * calls this right after the new After photo is inserted) and by the
 * "manually pair two photos I already have" control for photos uploaded
 * before this feature existed. Symmetric: sets photo_role/paired_photo_id
 * on BOTH rows, so either id can be looked up directly without a join.
 */
function paintops_pair_photos(int $beforeId, int $afterId, int $jobId): bool {
    global $wpdb;

    if ($beforeId <= 0 || $afterId <= 0 || $jobId <= 0 || $beforeId === $afterId) {
        return false;
    }

    $table = paintops_table('job_photos');
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT id, job_id, context, photo_role, paired_photo_id FROM %i WHERE id IN (%d, %d) AND job_id = %d AND context = 'photo'", $table, $beforeId, $afterId, $jobId), ARRAY_A);

    if (!is_array($rows) || count($rows) !== 2) {
        return false; // One or both ids don't exist, aren't on this job, or aren't a plain photo.
    }

    foreach ($rows as $row) {
        if (!empty($row['paired_photo_id'])) {
            return false; // Already part of another pair, unpair it first.
        }
    }

    $beforeUpdated = $wpdb->update($table, ['photo_role' => 'before', 'paired_photo_id' => $afterId], ['id' => $beforeId], ['%s', '%d'], ['%d']);
    $afterUpdated = $wpdb->update($table, ['photo_role' => 'after', 'paired_photo_id' => $beforeId], ['id' => $afterId], ['%s', '%d'], ['%d']);

    return $beforeUpdated !== false && $afterUpdated !== false;
}

/**
 * Breaks an existing pair, clears photo_role/paired_photo_id on both
 * sides so each photo goes back to being a plain, ordinary photo. Called
 * directly from a manual "Unpair" action, and internally from
 * paintops_delete_job_photo() when one half of a pair is deleted, so the
 * survivor never keeps a paired_photo_id pointing at a row that no
 * longer exists.
 */
function paintops_unpair_photo(int $photoId, int $jobId): bool {
    global $wpdb;

    if ($photoId <= 0 || $jobId <= 0) {
        return false;
    }

    $table = paintops_table('job_photos');
    $row = $wpdb->get_row($wpdb->prepare(
        "SELECT id, paired_photo_id FROM %i WHERE id = %d AND job_id = %d LIMIT 1", $table, $photoId, $jobId));

    if (!$row) {
        return false;
    }

    $partnerId = (int) ($row->paired_photo_id ?? 0);

    $wpdb->update($table, ['photo_role' => null, 'paired_photo_id' => null], ['id' => $photoId], ['%s', '%s'], ['%d']);

    if ($partnerId > 0) {
        $wpdb->update($table, ['photo_role' => null, 'paired_photo_id' => null], ['id' => $partnerId, 'job_id' => $jobId], ['%s', '%s'], ['%d', '%d']);
    }

    return true;
}

/**
 * How many photos (or receipts) a job currently has in a given bucket,
 * used both to enforce the cap server-side and to show a "12 / 15" style
 * count in the UI.
 */
function paintops_count_job_photos(int $job_id, string $context = 'photo'): int {
    global $wpdb;

    if ($job_id <= 0) {
        return 0;
    }

    return (int) $wpdb->get_var($wpdb->prepare(
        "SELECT COUNT(*) FROM %i WHERE job_id = %d AND context = %s", paintops_table('job_photos'), $job_id, $context));
}

/**
 * How many receipts are attached to one specific expense line item,
 * shown as a small hint next to that row in the Expenses table (see
 * paintops_render_job_financial_box()).
 */
function paintops_count_job_photos_for_expense(int $expense_id): int {
    global $wpdb;

    if ($expense_id <= 0) {
        return 0;
    }

    return (int) $wpdb->get_var($wpdb->prepare(
        "SELECT COUNT(*) FROM %i WHERE expense_id = %d AND context = 'receipt'", paintops_table('job_photos'), $expense_id));
}

/**
 * Confirms an expense row both exists and belongs to the given job,
 * checked before accepting a receipt upload against it, so a crafted
 * expense_id from a different job can never be used to attach a
 * receipt where it doesn't belong.
 */
function paintops_expense_belongs_to_job(int $expense_id, int $job_id): bool {
    global $wpdb;

    if ($expense_id <= 0 || $job_id <= 0) {
        return false;
    }

    $found = $wpdb->get_var($wpdb->prepare(
        "SELECT id FROM %i WHERE id = %d AND job_id = %d LIMIT 1", paintops_table('job_expenses'), $expense_id, $job_id));

    return (int) $found === $expense_id;
}

/**
 * Delete every receipt attached to one expense line item, called when
 * that line item is removed from Financials (see
 * paintops_save_job_financials_from_post()), since a receipt for a deleted
 * expense has nothing left to document.
 */
function paintops_delete_job_photos_for_expense(int $expense_id, int $job_id): void {
    global $wpdb;

    if ($expense_id <= 0 || $job_id <= 0) {
        return;
    }

    $table = paintops_table('job_photos');
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT id FROM %i WHERE expense_id = %d AND job_id = %d AND context = 'receipt'", $table, $expense_id, $job_id));

    foreach ($rows as $row) {
        paintops_delete_job_photo((int) $row->id, $job_id);
    }
}

/**
 * Pull a single-file array out of a $_FILES entry that came from an
 * array-named file input (e.g. name="expense_receipt_file[]", one
 * input per expense row in paintops_render_job_financial_box(), so a receipt
 * can be uploaded in the very same submit that creates or edits its
 * expense line item). PHP reports these as parallel arrays keyed by
 * field (name/type/tmp_name/error/size), each indexed the same way the
 * row was indexed, this reassembles index $index back into the
 * ordinary single-file shape paintops_handle_job_photo_upload() expects.
 * Returns null if nothing was chosen at that index.
 */
function paintops_extract_indexed_file(array $files, $index): ?array {
    if (!isset($files['name'][$index]) || $files['name'][$index] === '') {
        return null;
    }

    return [
        'name'     => $files['name'][$index],
        'type'     => $files['type'][$index] ?? '',
        'tmp_name' => $files['tmp_name'][$index] ?? '',
        'error'    => $files['error'][$index] ?? UPLOAD_ERR_NO_FILE,
        'size'     => $files['size'][$index] ?? 0,
    ];
}

/**
 * Handle one uploaded file (a single entry from $_FILES) for a job:
 * resize/compress it, register it as a normal WordPress media
 * attachment (so it lives in the Media Library, survives backups, and
 * works with any offload-to-S3 plugin), and record it in cs_job_photos.
 *
 * Returns the new row's ID on success, or a WP_Error explaining what
 * went wrong, most commonly hitting the photo cap, which is enforced
 * here (not just in the UI, which merely hides the upload control once
 * the limit is reached, that's trivially bypassed by a raw POST).
 */

/**
 * intermediate_image_sizes_advanced filter callback, keeps only
 * 'medium', dropping thumbnail/medium_large/large/theme-custom sizes.
 * See the doc comment at its add_filter() call site in
 * paintops_handle_job_photo_upload() for why.
 */
function paintops_restrict_photo_image_sizes(array $sizes): array {
    return array_intersect_key($sizes, ['medium' => true]);
}

function paintops_handle_job_photo_upload(int $job_id, array $file, string $context = 'photo', ?int $expense_id = null, string $notes = '', string $photo_role = '', ?int $pair_with_before_id = null) {
    if ($job_id <= 0) {
        return new WP_Error('paintops_bad_job', 'Invalid job.');
    }

    if (empty($file['name']) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
        return new WP_Error('paintops_no_file', 'No file was selected.');
    }

    if ($context === 'photo') {
        $limit = paintops_job_photo_limit();
        if (paintops_count_job_photos($job_id, 'photo') >= $limit) {
            return new WP_Error(
                'paintops_photo_limit',
                sprintf('This job already has the maximum of %d photos. Delete one before adding another, or raise the limit under PaintOps &gt; Business &gt; Company Profile.', $limit)
            );
        }
    } elseif ($context === 'receipt') {
        if ($expense_id === null || $expense_id <= 0 || !paintops_expense_belongs_to_job($expense_id, $job_id)) {
            return new WP_Error('paintops_bad_expense', 'This receipt is not linked to a valid expense line item on this job.');
        }
        // One receipt per line item is enough, delete the existing one
        // first if it needs to be replaced. Enforced here (not just by
        // hiding the upload control once one exists), since that's easy
        // to bypass with a raw POST.
        if (paintops_count_job_photos_for_expense($expense_id) >= 1) {
            return new WP_Error('paintops_receipt_limit', 'This expense already has a receipt attached. Delete it first if you want to replace it.');
        }
    } else {
        return new WP_Error('paintops_bad_context', 'Invalid photo type.');
    }

    // Core admin includes loaded the documented way for front-end media
    // handling (the permitted require_once exception): wp_handle_upload()
    // below comes from file.php, and wp_insert_attachment's metadata step
    // further down (wp_generate_attachment_metadata) comes from image.php.
    // Used immediately after loading, never called directly over HTTP.
    require_once ABSPATH . 'wp-admin/includes/image.php';
    require_once ABSPATH . 'wp-admin/includes/file.php';

    $upload = wp_handle_upload($file, ['test_form' => false]);

    if (isset($upload['error'])) {
        return new WP_Error('paintops_upload_failed', $upload['error']);
    }

    // The accept= on the file input is just a UI hint, a crafted POST
    // could still submit any file type wp_handle_upload() allows, so
    // enforce server-side. Job PHOTOS are images only. RECEIPTS also
    // accept PDF (2026-07-22, Mike's request: emailed receipts and
    // Home Depot account downloads arrive as PDFs).
    $upload_mime = (string) $upload['type'];
    $is_pdf_receipt = ($context === 'receipt' && $upload_mime === 'application/pdf');
    if (!$is_pdf_receipt && strpos($upload_mime, 'image/') !== 0) {
        wp_delete_file($upload['file']);
        return new WP_Error('paintops_not_an_image', $context === 'receipt'
            ? 'Receipts can be an image or a PDF.'
            : 'Only image files can be uploaded here.');
    }

    // Read whatever EXIF the photo already had BEFORE the resize/
    // recompress step below, which strips it, this is the only
    // moment that data is still on disk to read. PDFs carry no EXIF
    // and must not go through the image resizer.
    $exif = $is_pdf_receipt ? [] : paintops_extract_photo_exif($upload['file']);

    // Shrink before it ever becomes an attachment, this is the actual
    // storage-size fix, not something bolted on after the fact.
    if (!$is_pdf_receipt) {
        paintops_resize_and_compress_image($upload['file']);
    }

    $attachment_id = wp_insert_attachment([
        'post_mime_type' => $upload['type'],
        'post_title'     => sanitize_file_name(basename($upload['file'])),
        'post_content'   => '',
        'post_status'    => 'inherit',
    ], $upload['file']);

    if (!$attachment_id || is_wp_error($attachment_id)) {
        return new WP_Error('paintops_attach_failed', 'Could not save the uploaded photo.');
    }

    // Left to its defaults, wp_generate_attachment_metadata() generates
    // every image size WordPress core AND the active theme have
    // registered, thumbnail, medium, medium_large, large, whatever
    // custom sizes a theme adds, for every single photo/receipt.
    // paintops_get_job_photos() only ever reads 'medium' (grid thumbnail)
    // and 'full' (the original file itself, already capped to ~1600px by
    // paintops_resize_and_compress_image() above, not a generated size,
    // so it exists regardless of this filter). Everything else was pure
    // wasted disk space that multiplies with every photo on every job
    // (one upload was producing 6 files on disk). Scoped narrowly to
    // just this one call, so the rest of the site's media library,
    // blog post images uploaded the normal way, etc., keeps its usual
    // theme-registered sizes.
    if (!$is_pdf_receipt) {
        add_filter('intermediate_image_sizes_advanced', 'paintops_restrict_photo_image_sizes');
        $attach_data = wp_generate_attachment_metadata($attachment_id, $upload['file']);
        remove_filter('intermediate_image_sizes_advanced', 'paintops_restrict_photo_image_sizes');
        wp_update_attachment_metadata($attachment_id, $attach_data);
    }

    global $wpdb;

    $data = [
        'job_id'        => $job_id,
        'context'       => $context,
        'attachment_id' => $attachment_id,
        'uploaded_by'   => get_current_user_id(),
    ];
    $formats = ['%d', '%s', '%d', '%d'];

    $notes = trim(wp_strip_all_tags($notes));
    if ($notes !== '') {
        $data['notes'] = $notes;
        $formats[] = '%s';
    }

    if ($exif['taken_at'] !== null) {
        $data['taken_at'] = $exif['taken_at'];
        $formats[] = '%s';
    }
    if ($exif['gps_lat'] !== null && $exif['gps_lng'] !== null) {
        $data['gps_lat'] = $exif['gps_lat'];
        $data['gps_lng'] = $exif['gps_lng'];
        $formats[] = '%f';
        $formats[] = '%f';
    }

    if ($context === 'receipt') {
        $data['expense_id'] = $expense_id;
        $formats[] = '%d';
    }

    // Before/After tagging, see paintops_pair_photos() in this file.
    // A plain "mark this as a Before shot" upload just tags the role;
    // it isn't paired to anything yet (see
    // paintops_get_unpaired_before_photos()). $pair_with_before_id is
    // the opposite case, this upload IS the matching After shot for
    // an existing Before photo (typically just captured via the live
    // alignment-overlay camera UI), pairing itself happens below,
    // after the insert, since it needs this row's own new id.
    if ($context === 'photo' && $photo_role === 'before') {
        $data['photo_role'] = 'before';
        $formats[] = '%s';
    }

    $inserted = $wpdb->insert(paintops_table('job_photos'), $data, $formats);

    // $wpdb->insert_id is only meaningful right after a query that
    // actually generated one, if the insert itself failed (e.g. the
    // cs_job_photos table doesn't exist yet because a pending version
    // migration never ran), $wpdb->insert() returns false/0 and
    // insert_id is left stale or 0. Blindly returning insert_id here
    // used to report a fake success ("photo row #0") even when nothing
    // was actually saved, check the real result instead, and clean up
    // the attachment we just created so it doesn't become an orphaned
    // Media Library entry with no cs_job_photos row pointing at it.
    if (!$inserted) {
        wp_delete_attachment($attachment_id, true);
        $db_error = $wpdb->last_error ?: 'unknown database error';
        return new WP_Error('paintops_db_insert_failed', 'Could not save the photo record: ' . $db_error);
    }

    $new_photo_id = (int) $wpdb->insert_id;

    // Pair this brand-new photo (the After shot) to the Before photo it
    // was aligned against, if one was specified. Deliberately not
    // treated as a failure of the upload itself if pairing doesn't take
    // (e.g. the Before photo was deleted or paired to something else in
    // the moment between the camera opening and this submit), the
    // photo is safely saved either way, it just won't show up as a
    // pair, and can still be paired manually afterward.
    if ($context === 'photo' && $pair_with_before_id !== null && $pair_with_before_id > 0 && function_exists('paintops_pair_photos')) {
        paintops_pair_photos($pair_with_before_id, $new_photo_id, $job_id);
    }

    return $new_photo_id;
}

/**
 * Whether the current user may delete a photo/receipt that was uploaded
 * by $uploaded_by. Takes the already-known uploader ID directly (no DB
 * query) so it's cheap to call once per row inside a gallery loop,
 * paintops_user_can_delete_job_photo() below is the DB-verifying version
 * used wherever the row itself isn't already in hand (POST handlers).
 *
 * True for an Administrator (manage_options), or for whoever actually
 * uploaded this specific item, deliberately stricter than
 * paintops_user_can_manage_job_photos()/paintops_user_can_add_job_receipt(),
 * which govern viewing and uploading. With several staff/workers now
 * regularly adding photos to the same job, "anyone who can see this job
 * can delete anything on it" meant one person's photo could be removed
 * by someone else entirely, not something anyone asked for.
 */
function paintops_can_current_user_delete_upload(int $uploaded_by): bool {
    if (current_user_can('manage_options')) {
        return true;
    }

    $current_user_id = get_current_user_id();

    return $uploaded_by > 0 && $current_user_id > 0 && $uploaded_by === $current_user_id;
}

/**
 * DB-verifying version of paintops_can_current_user_delete_upload(),
 * looks up $photo_id's actual uploader (scoped to $job_id, so a crafted
 * ID belonging to a different job can't be probed) rather than trusting
 * a caller-supplied value. Used by the POST handlers below, since a
 * submitted photo_id can't be trusted to be one the user was ever shown.
 */
function paintops_user_can_delete_job_photo(int $photo_id, int $job_id): bool {
    global $wpdb;

    if ($photo_id <= 0 || $job_id <= 0) {
        return false;
    }

    $uploaded_by = $wpdb->get_var($wpdb->prepare(
        "SELECT uploaded_by FROM %i WHERE id = %d AND job_id = %d LIMIT 1", paintops_table('job_photos'), $photo_id, $job_id));

    if ($uploaded_by === null) {
        return false;
    }

    return paintops_can_current_user_delete_upload((int) $uploaded_by);
}

/**
 * Delete one photo/receipt, removes both the cs_job_photos row and
 * the underlying media attachment (force-deleted, skipping the trash,
 * since a job photo has no other use once removed here). Scoped to a
 * specific job_id so a crafted request can't delete a photo belonging
 * to a job the requester was never shown.
 */
function paintops_delete_job_photo(int $photo_id, int $job_id): bool {
    global $wpdb;

    if ($photo_id <= 0 || $job_id <= 0) {
        return false;
    }

    // Defense in depth: both callers (paintops_process_job_photos_post()/
    // paintops_process_job_receipts_post()) already check this and show a
    // specific message before ever getting here, enforcing it again at
    // this single low-level chokepoint means no future call site can
    // accidentally let one user delete another user's upload.
    if (!paintops_user_can_delete_job_photo($photo_id, $job_id)) {
        return false;
    }

    $table = paintops_table('job_photos');
    $row = $wpdb->get_row($wpdb->prepare(
        "SELECT id, attachment_id FROM %i WHERE id = %d AND job_id = %d LIMIT 1", $table, $photo_id, $job_id));

    if (!$row) {
        return false;
    }

    // Clear the pairing on both sides BEFORE deleting, otherwise the
    // surviving half of a Before/After pair would be left with a
    // paired_photo_id pointing at a row that no longer exists, and
    // paintops_get_before_after_pairs()/paintops_get_unpaired_before_photos()
    // would either silently drop it or (worse) trip over a missing
    // partner. Harmless no-op call if this photo was never paired.
    if (function_exists('paintops_unpair_photo')) {
        paintops_unpair_photo($photo_id, $job_id);
    }

    $wpdb->delete($table, ['id' => $photo_id], ['%d']);

    if ((int) $row->attachment_id > 0) {
        wp_delete_attachment((int) $row->attachment_id, true);
    }

    return true;
}

/**
 * Add/edit the free-text caption on an existing photo/receipt row.
 * Gated by the same paintops_user_can_delete_job_photo() check used for
 * deleting, uploader or Administrator, since a note is as much
 * "editing someone's upload" as removing it is. Pass '' to clear an
 * existing note. Scoped to $job_id so a crafted photo_id can't target a
 * row on a different job.
 */
function paintops_update_job_photo_notes(int $photo_id, int $job_id, string $notes): bool {
    global $wpdb;

    if ($photo_id <= 0 || $job_id <= 0) {
        return false;
    }

    if (!paintops_user_can_delete_job_photo($photo_id, $job_id)) {
        return false;
    }

    $table = paintops_table('job_photos');
    $notes = trim(wp_strip_all_tags($notes));

    $updated = $wpdb->update(
        $table,
        ['notes' => $notes],
        ['id' => $photo_id, 'job_id' => $job_id],
        ['%s'],
        ['%d', '%d']
    );

    return $updated !== false;
}

/**
 * Whether the current user may view/add/delete Job Photos for a
 * specific job, true for any staff-level user, or for a Painter/
 * Helper who is actually assigned to this job (reuses the same check
 * job-view.php uses to decide whether a worker can see the job page at
 * all) AND whose "Can upload photos" permission is checked on PaintOps >
 * Painters/Helpers (see paintops_worker_has_permission(); defaults to
 * true, so this stays open for every existing worker unless an
 * Administrator specifically unchecks it for someone). Deliberately
 * broader than paintops_user_can_manage() alone, a painter documenting
 * before/after shots on their own job site is exactly the operational
 * use case Job Photos exists for, and photos carry none of the
 * sensitivity that keeps Job Financials locked to Administrators.
 */
function paintops_user_can_manage_job_photos(int $job_id): bool {
    if (paintops_user_can_manage()) {
        return true;
    }

    if (!function_exists('paintops_user_can_view_job') || !paintops_user_can_view_job($job_id)) {
        return false;
    }

    if (!function_exists('paintops_worker_has_permission')) {
        return true;
    }

    // (2026-07-19, audit polish) A worker with the receipts permission
    // but photos UNCHECKED used to be unable to leave even a text note,
    // notes ride this photos gate, and "can log an expense but can't say
    // 'ran out of caulk'" was a permission dead-end nobody intended.
    // Either permission now opens this page; the photo-UPLOAD paths
    // stay effectively photo-permission territory in spirit, but the
    // sensitive thing here was never the note text.
    return paintops_worker_has_permission('upload_photos')
        || paintops_worker_has_permission('upload_receipts');
}

/**
 * Process any photo-related POST on this request: uploading a new job
 * photo or deleting an existing one. Called from the Job Photos page
 * (see manage-jobs.php's ?jobPhotos= handler) before the gallery below
 * is rendered, so the page always reflects the just-submitted change.
 * Returns a status message to display, or '' if nothing was submitted.
 */
function paintops_process_job_photos_post(int $job_id): string {
    if ($job_id <= 0 || !paintops_user_can_manage_job_photos($job_id)) {
        return '';
    }

    // Job Notes composer: a note (text) with an OPTIONAL photo. A photo goes in
    // as a context 'photo' row (so Before/After can still pair it); a text-only
    // submission becomes a context 'note' row. Either alone is valid; both empty
    // is not.
    if (isset($_POST['paintops_add_job_note'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);
        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $notes = sanitize_textarea_field(wp_unslash($_POST['job_note_text'] ?? ''));
        // 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
        $file  = paintops_sanitize_upload($_FILES['job_note_photo'] ?? null);
        $has_file = is_array($file) && !empty($file['name']);

        if ($notes === '' && !$has_file) {
            return '<div class="paintops-notice is-error">Add a note or a photo to log an entry.</div>';
        }

        if ($has_file) {
            $result = paintops_handle_job_photo_upload($job_id, $file, 'photo', null, $notes);
            if (is_wp_error($result)) {
                return '<div class="paintops-notice is-error">' . wp_kses($result->get_error_message(), ['br' => []]) . '</div>';
            }
            return '<div class="paintops-notice is-success">Note added' . ($notes !== '' ? ' with photo' : ' (photo)') . '.</div>';
        }

        $result = paintops_add_job_text_note($job_id, $notes);
        if (is_wp_error($result)) {
            return '<div class="paintops-notice is-error">' . esc_html($result->get_error_message()) . '</div>';
        }
        return '<div class="paintops-notice is-success">Note added.</div>';
    }

    if (isset($_POST['paintops_upload_job_photo'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        if (!isset($_FILES['job_photo_file'])) {
            return '<div class="paintops-notice is-error">The server never received a file field at all, this points to a hosting-level limit (post_max_size) or a form/enctype problem, not the photo itself.</div>';
        }

        // 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
        $file = paintops_sanitize_upload($_FILES['job_photo_file']);

        if (empty($file['name'])) {
            return '<div class="paintops-notice is-error">Please choose a photo to upload.</div>';
        }

        $notes = sanitize_textarea_field(wp_unslash($_POST['job_photo_notes'] ?? ''));

        $result = paintops_handle_job_photo_upload($job_id, $file, 'photo', null, $notes);

        if (is_wp_error($result)) {
            return '<div class="paintops-notice is-error">' . wp_kses($result->get_error_message(), ['br' => []]) . ' (Details: name=' . esc_html(sanitize_file_name((string) $file['name'])) . ', size=' . esc_html((string) (int) ($file['size'] ?? 0)) . ' bytes, php error code=' . esc_html((string) (int) ($file['error'] ?? 0)) . ')</div>';
        }

        return '<div class="paintops-notice is-success">Photo uploaded (attachment/photo row #' . esc_html((string) $result) . ').</div>';
    }

    if (isset($_POST['paintops_delete_job_photo'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $photo_id = (int) $_POST['paintops_delete_job_photo'];

        if (!paintops_user_can_delete_job_photo($photo_id, $job_id)) {
            return '<div class="paintops-notice is-error">You can only delete a photo you uploaded yourself, ask an Administrator if it needs to come down.</div>';
        }

        if (paintops_delete_job_photo($photo_id, $job_id)) {
            return '<div class="paintops-notice is-success">Photo deleted.</div>';
        }

        return '<div class="paintops-notice is-error">Could not delete that photo.</div>';
    }

    if (isset($_POST['paintops_save_job_photo_notes'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $photo_id = (int) $_POST['paintops_save_job_photo_notes'];
        $notes = sanitize_textarea_field(wp_unslash($_POST['job_photo_notes_edit'] ?? ''));

        if (!paintops_user_can_delete_job_photo($photo_id, $job_id)) {
            return '<div class="paintops-notice is-error">You can only edit the note on a photo you uploaded yourself, ask an Administrator if it needs to change.</div>';
        }

        if (paintops_update_job_photo_notes($photo_id, $job_id, $notes)) {
            return '<div class="paintops-notice is-success">Note saved.</div>';
        }

        return '<div class="paintops-notice is-error">Could not save that note.</div>';
    }

    if (isset($_POST['paintops_upload_before_photo'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        if (!isset($_FILES['job_before_photo_file'])) {
            return '<div class="paintops-notice is-error">The server never received a file field at all, this points to a hosting-level limit (post_max_size) or a form/enctype problem, not the photo itself.</div>';
        }

        // 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
        $file = paintops_sanitize_upload($_FILES['job_before_photo_file']);

        if (empty($file['name'])) {
            return '<div class="paintops-notice is-error">Please choose or take a "Before" photo to upload.</div>';
        }

        $notes = sanitize_textarea_field(wp_unslash($_POST['job_before_photo_notes'] ?? ''));

        $result = paintops_handle_job_photo_upload($job_id, $file, 'photo', null, $notes, 'before');

        if (is_wp_error($result)) {
            return '<div class="paintops-notice is-error">' . wp_kses($result->get_error_message(), ['br' => []]) . '</div>';
        }

        // Tell the Before/After box (rendered later this same request) to
        // scroll the browser back down to the "next step" card instead of
        // leaving it parked at the top of the page after the reload, that
        // is the exact spot where the just-saved Before now waits for its
        // matching After. See paintops_render_before_after_box().
        $GLOBALS['paintops_ba_scroll'] = 'waiting';

        return '<div class="paintops-notice is-success">Before photo uploaded. Use "Take Matching After Photo" below when you\'re ready to line up the After shot.</div>';
    }

    if (isset($_POST['paintops_upload_after_photo'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $before_id = (int) $_POST['paintops_upload_after_photo'];

        if ($before_id <= 0 || !in_array($before_id, array_column(paintops_get_unpaired_before_photos($job_id), 'id'), false)) {
            return '<div class="paintops-notice is-error">That Before photo is no longer available to pair with, it may have already been matched or deleted. Refresh the page and try again.</div>';
        }

        if (!isset($_FILES['job_after_photo_file'])) {
            return '<div class="paintops-notice is-error">The server never received a file field at all, this points to a hosting-level limit (post_max_size) or a form/enctype problem, not the photo itself.</div>';
        }

        // 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
        $file = paintops_sanitize_upload($_FILES['job_after_photo_file']);

        if (empty($file['name'])) {
            return '<div class="paintops-notice is-error">Please take or choose the matching After photo.</div>';
        }

        $result = paintops_handle_job_photo_upload($job_id, $file, 'photo', null, '', '', $before_id);

        if (is_wp_error($result)) {
            return '<div class="paintops-notice is-error">' . wp_kses($result->get_error_message(), ['br' => []]) . '</div>';
        }

        // Land the reloaded page on the fresh pair rather than the top.
        $GLOBALS['paintops_ba_scroll'] = 'pairs';

        return '<div class="paintops-notice is-success">After photo uploaded and paired. Your Before/After comparison is ready below.</div>';
    }

    if (isset($_POST['paintops_pair_existing_photos'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $before_id = (int) ($_POST['pair_before_photo_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
        $after_id = (int) ($_POST['pair_after_photo_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable

        if ($before_id <= 0 || $after_id <= 0) {
            return '<div class="paintops-notice is-error">Choose both a Before and an After photo to pair.</div>';
        }

        if ($before_id === $after_id) {
            return '<div class="paintops-notice is-error">Those are the same photo, choose two different photos for Before and After.</div>';
        }

        // Re-check against the live unpaired list before attempting the
        // pair, so a stale page (photo paired or deleted from another
        // tab/user since this page rendered, or a back-button resubmit
        // of an earlier pairing) gets a specific explanation instead of
        // the old catch-all "make sure both are unpaired photos on this
        // job" message. paintops_pair_photos() still re-verifies
        // everything itself, this is purely for a better error.
        $unpairedIds = array_map('intval', array_column(paintops_get_unpaired_photos($job_id), 'id'));

        if (!in_array($before_id, $unpairedIds, true) || !in_array($after_id, $unpairedIds, true)) {
            return '<div class="paintops-notice is-error">One of those photos is no longer available to pair, it may have just been paired or deleted, or this may be a resubmit of a pairing that already went through. Refresh the page and check the Before/After list below.</div>';
        }

        if (paintops_pair_photos($before_id, $after_id, $job_id)) {
            $GLOBALS['paintops_ba_scroll'] = 'pairs';
            return '<div class="paintops-notice is-success">Photos paired as Before/After.</div>';
        }

        return '<div class="paintops-notice is-error">Could not pair those photos, make sure both are unpaired photos on this job.</div>';
    }

    if (isset($_POST['paintops_unpair_job_photo'])) {
        $nonce_ok = isset($_POST['paintops_job_photos_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_photos_nonce'])), 'paintops_job_photos_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $photo_id = (int) $_POST['paintops_unpair_job_photo'];

        if (paintops_unpair_photo($photo_id, $job_id)) {
            $GLOBALS['paintops_ba_scroll'] = 'box';
            return '<div class="paintops-notice is-success">Pair removed, both photos are now ordinary, unpaired photos.</div>';
        }

        return '<div class="paintops-notice is-error">Could not unpair that photo.</div>';
    }

    return '';
}

/**
 * Editable Job Photos box, staff only. Own form, own upload button,
 * same "own form, own save button" pattern used for Paint Colors and
 * Job Financials, so adding a photo never requires re-saving the whole
 * job. Deliberately no capture="environment" on the file input, that
 * attribute skips straight to the camera on mobile and hides the
 * "Photo Library" option, which was more restrictive than intended
 * (staff often want to attach an existing photo, not always take a new
 * one on the spot).
 */
function paintops_render_job_photos_box(int $job_id): void {
    if (!paintops_user_can_manage_job_photos($job_id)) {
        return;
    }

    $limit = paintops_job_photo_limit();
    $count = paintops_count_job_photos($job_id, 'photo');
    $photos = paintops_get_job_photos($job_id, 'photo');
    $at_limit = $count >= $limit;
    ?>
    <div class="cs-financial-box">
        <h3>Job Photos <span style="font-weight:400;font-size:13px;color:#777;">(<?php echo esc_html((string) $count); ?> / <?php echo esc_html((string) $limit); ?>)</span></h3>
        <p style="color:#777;font-size:13px;margin-top:0;">Before/after and progress photos for this job. Snap a new one right from your phone's camera, or upload an existing photo.</p>

        <?php if ($photos): ?>
            <div style="display:flex;flex-wrap:wrap;gap:14px;margin-bottom:16px;">
                <?php foreach ($photos as $photo): ?>
                    <?php $canEditThis = paintops_can_current_user_delete_upload((int) $photo['uploaded_by']); ?>
                    <div style="width:170px;">
                        <a href="<?php echo esc_url($photo['full_url']); ?>" target="_blank" rel="noopener noreferrer">
                            <img src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" style="width:170px;height:170px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
                        </a>

                        <?php if (!empty($photo['photo_role'])): ?>
                            <span style="display:inline-block;margin-top:4px;padding:1px 7px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;<?php echo esc_attr($photo['photo_role'] === 'before' ? 'background:#eee;color:#555;' : 'background:#dff0d8;color:#3c763d;'); ?>"><?php echo esc_html(ucfirst((string) $photo['photo_role'])); ?><?php echo esc_attr(!empty($photo['paired_photo_id']) ? '' : ' (unpaired)'); ?></span>
                        <?php endif; ?>

                        <div style="font-size:11px;color:#888;margin-top:4px;line-height:1.5;">
                            Uploaded <?php echo esc_html($photo['uploaded_at_display']); ?> by <?php echo esc_html($photo['uploaded_by_name']); ?>
                            <?php if ($photo['taken_at_display'] !== ''): ?>
                                <br>Taken <?php echo esc_html($photo['taken_at_display']); ?>
                            <?php endif; ?>
                            <?php if ($photo['map_url'] !== ''): ?>
                                <br><a href="<?php echo esc_url($photo['map_url']); ?>" target="_blank" rel="noopener noreferrer">View location</a>
                            <?php endif; ?>
                        </div>

                        <?php if ($canEditThis): ?>
                            <form method="post" enctype="multipart/form-data" style="margin-top:6px;">
                                <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                                <textarea name="job_photo_notes_edit" placeholder="Add a note..." rows="2" style="width:100%;font-size:12px;box-sizing:border-box;resize:vertical;"><?php echo esc_textarea($photo['notes'] ?? ''); ?></textarea>
                                <button type="submit" name="paintops_save_job_photo_notes" value="<?php echo esc_attr((string) $photo['id']); ?>" class="button button-small" style="width:100%;margin-top:2px;">Save Note</button>
                            </form>
                        <?php elseif (!empty($photo['notes'])): ?>
                            <p style="font-size:12px;color:#555;margin:6px 0 0;"><?php echo esc_html($photo['notes']); ?></p>
                        <?php endif; ?>

                        <?php if ($canEditThis): ?>
                        <form method="post" enctype="multipart/form-data" onsubmit="return confirm('Delete this photo?');" style="margin-top:4px;">
                            <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                            <button type="submit" name="paintops_delete_job_photo" value="<?php echo esc_attr((string) $photo['id']); ?>" class="button button-small" style="width:100%;">Delete</button>
                        </form>
                        <?php endif; ?>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>

        <?php if ($at_limit): ?>
            <p style="color:#b00;">This job has reached the <?php echo esc_html((string) $limit); ?>-photo limit. Delete one above to add another, or raise the limit under PaintOps &gt; Business &gt; Company Profile.</p>
        <?php else: ?>
            <form method="post" enctype="multipart/form-data">
                <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                <?php paintops_render_photo_picker_input('job_photo_file', '', false, false, true); ?>
                <div style="margin-top:8px;">
                    <textarea name="job_photo_notes" placeholder="Optional note (e.g. &quot;Second coat, north wall&quot;)" rows="2" style="width:100%;max-width:400px;font-size:13px;box-sizing:border-box;resize:vertical;"></textarea>
                </div>
                <button type="submit" name="paintops_upload_job_photo" value="1" class="paintops-btn paintops-btn-primary" style="margin-top:8px;">Upload Photo</button>
            </form>
        <?php endif; ?>
    </div>
    <?php
    paintops_render_before_after_box($job_id);
    ?>
    <?php
    paintops_echo_photo_compress_script();
    paintops_echo_photo_picker_script();
    paintops_echo_before_after_capture_script($job_id);
    paintops_echo_before_after_compare_script();
}

/**
 * "Before / After" box, the QuoteIQ-style alignment overlay + slider
 * comparison feature Mike asked for. Three parts, in the order someone
 * would actually use them:
 *
 *  1. Existing pairs, each with a "View Comparison" button (see
 *     paintops_echo_before_after_compare_script()) and an Unpair option.
 *  2. Before photos still waiting for their matching After shot, each
 *     with a "Take Matching After Photo" button that opens the live
 *     camera alignment overlay (see
 *     paintops_echo_before_after_capture_script()).
 *  3. Two ways to start a new pair: upload a fresh "Before" photo (step
 *     one of the guided flow above), or manually link two photos that
 *     already exist on this job, covers anything uploaded before this
 *     feature shipped, or a photo taken the ordinary way that turns out
 *     to make a good Before/After pair after the fact.
 *
 * Deliberately its own box below the main gallery rather than woven
 * into it, Before/After pairing is an opt-in add-on to Job Photos, not
 * a replacement for the plain photo list every existing job already has.
 */
function paintops_render_before_after_box(int $job_id, bool $bare = false): void {
    $pairs = paintops_get_before_after_pairs($job_id);
    $unpairedBefores = paintops_get_unpaired_before_photos($job_id);
    $unpairedAll = paintops_get_unpaired_photos($job_id);
    ?>
    <?php if (!$bare): ?>
    <div class="cs-financial-box" id="paintops-before-after" style="margin-top:16px;">
        <h3>Before / After</h3>
    <?php endif; ?>
        <p style="color:#777;font-size:13px;margin-top:0;">Line up a matching "After" shot against a "Before" photo, then compare them side by side with a drag slider.</p>

        <?php if ($pairs): ?>
            <div id="paintops-ba-pairs" style="display:flex;flex-wrap:wrap;gap:14px;margin-bottom:16px;">
                <?php foreach ($pairs as $pair): ?>
                    <?php
                    $before = $pair['before'];
                    $after = $pair['after'];
                    $canUnpair = paintops_can_current_user_delete_upload((int) $before['uploaded_by']) || paintops_can_current_user_delete_upload((int) $after['uploaded_by']);
                    ?>
                    <div style="width:220px;">
                        <div style="display:flex;gap:4px;">
                            <img src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:108px;height:108px;object-fit:cover;border-radius:6px 0 0 6px;border:1px solid #ddd;display:block;">
                            <img src="<?php echo esc_url($after['url']); ?>" alt="After" style="width:108px;height:108px;object-fit:cover;border-radius:0 6px 6px 0;border:1px solid #ddd;display:block;">
                        </div>
                        <?php paintops_render_compare_trigger_button($before['full_url'], $after['full_url']); ?>
                        <?php if ($canUnpair): ?>
                            <form method="post" enctype="multipart/form-data" style="margin-top:4px;">
                                <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                                <button type="submit" name="paintops_unpair_job_photo" value="<?php echo esc_attr((string) $before['id']); ?>" class="button button-small" style="width:100%;">Unpair</button>
                            </form>
                        <?php endif; ?>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>

        <?php if ($unpairedBefores): ?>
            <?php
            // The just-uploaded Before is the LAST item (paintops_get_job_photos
            // orders by id ASC), so flag that card as the one to act on. The
            // whole panel is deliberately styled as a highlighted "do this
            // next" call-out because the #1 confusion here was that after
            // saving a Before the page jumped to the top and the always-on
            // "Add a Before Photo" form below read like "upload another one",
            // when the real next step is shooting the matching After.
            $lastWaitingIndex = count($unpairedBefores) - 1;
            ?>
            <div id="paintops-ba-waiting" style="margin-bottom:16px;padding:12px 14px;border:1px solid #cfe0ec;border-radius:8px;background:#f3f9fd;">
                <p style="font-weight:700;font-size:14px;margin:0 0 2px;color:#2f5f7a;">Next step: take the matching After photo</p>
                <p style="color:#666;font-size:12px;margin:0 0 10px;">Your Before photo is saved. Tap "Take Matching After Photo" to line up the same angle and shoot the After; the two pair up automatically.</p>
                <div style="display:flex;flex-wrap:wrap;gap:14px;">
                    <?php foreach ($unpairedBefores as $waitingIndex => $before): ?>
                        <div class="pp-ba-waiting-card<?php echo esc_attr($waitingIndex === $lastWaitingIndex ? ' pp-ba-just-added' : ''); ?>" style="width:140px;">
                            <img src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:140px;height:140px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
                            <?php paintops_render_before_after_capture_button((int) $before['id'], $before['full_url']); ?>
                        </div>
                    <?php endforeach; ?>
                </div>
            </div>
        <?php endif; ?>

        <div style="display:flex;flex-wrap:wrap;gap:24px;">
            <?php
            // Once this job already has some Before/After work, either a
            // Before waiting for its After OR a finished pair, starting yet
            // another Before is NOT the primary action, so the upload form
            // collapses into an out-of-the-way "Add another Before photo"
            // toggle. This is the fix for the prompt still reading "Add a
            // Before Photo" after a pair was completed. Only a truly fresh job
            // (no pairs and nothing waiting) shows it open and plainly labeled
            // as step one. Same form either way, captured once here so the two
            // presentations never drift.
            $hasExistingBaWork = !empty($unpairedBefores) || !empty($pairs);
            ob_start();
            ?>
                <form method="post" enctype="multipart/form-data">
                    <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                    <?php paintops_render_photo_picker_input('job_before_photo_file', '', false, false, true); ?>
                    <div style="margin-top:8px;">
                        <textarea name="job_before_photo_notes" placeholder="Optional note" rows="2" style="width:100%;font-size:13px;box-sizing:border-box;resize:vertical;"></textarea>
                    </div>
                    <button type="submit" name="paintops_upload_before_photo" value="1" class="paintops-btn paintops-btn-primary" style="margin-top:8px;">Upload Before Photo</button>
                </form>
            <?php
            $beforeUploadForm = ob_get_clean();
            ?>
            <div style="max-width:340px;">
                <?php if ($hasExistingBaWork): ?>
                    <details>
                        <summary style="cursor:pointer;color:#4682B4;font-weight:600;font-size:13px;">Add another Before photo</summary>
                        <div style="margin-top:10px;"><?php echo wp_kses($beforeUploadForm, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
                    </details>
                <?php else: ?>
                    <p style="font-weight:600;font-size:13px;margin-bottom:6px;">Add a Before Photo</p>
                    <?php echo wp_kses($beforeUploadForm, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
                <?php endif; ?>
            </div>

            <?php if (count($unpairedAll) >= 2): ?>
                <form method="post" enctype="multipart/form-data" style="max-width:340px;" id="paintops-pair-existing-form">
                    <p style="font-weight:600;font-size:13px;margin-bottom:6px;">Pair Two Existing Photos</p>
                    <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                    <?php
                    // Both dropdowns used to render the same list with no
                    // placeholder, so they defaulted to the SAME first
                    // photo, changing only one side then submitted the
                    // same id twice and hit the generic "Could not pair"
                    // error. Now each starts on a "Choose..." placeholder
                    // (value '', which the POST handler already rejects
                    // with "Choose both..."), option labels carry the
                    // photo's role tag so a 'before'-tagged shot is
                    // recognizable, and the script below disables
                    // whichever photo is selected on the other side.
                    $pairOptionLabel = static function (array $photo): string {
                        $label = 'Photo #' . (string) $photo['id'] . ', ' . (string) $photo['uploaded_at_display'];
                        if (!empty($photo['photo_role'])) {
                            $label .= ' (tagged ' . ucfirst((string) $photo['photo_role']) . ')';
                        }
                        return $label;
                    };
                    ?>
                    <label style="font-size:12px;display:block;margin-bottom:2px;">Before</label>
                    <select name="pair_before_photo_id" style="width:100%;margin-bottom:8px;">
                        <option value="">-- Choose the Before photo --</option>
                        <?php foreach ($unpairedAll as $photo): ?>
                            <option value="<?php echo esc_attr((string) $photo['id']); ?>"><?php echo esc_html($pairOptionLabel($photo)); ?></option>
                        <?php endforeach; ?>
                    </select>
                    <label style="font-size:12px;display:block;margin-bottom:2px;">After</label>
                    <select name="pair_after_photo_id" style="width:100%;margin-bottom:8px;">
                        <option value="">-- Choose the After photo --</option>
                        <?php foreach ($unpairedAll as $photo): ?>
                            <option value="<?php echo esc_attr((string) $photo['id']); ?>"><?php echo esc_html($pairOptionLabel($photo)); ?></option>
                        <?php endforeach; ?>
                    </select>
                    <button type="submit" name="paintops_pair_existing_photos" value="1" class="paintops-btn paintops-btn-secondary">Pair Photos</button>
                    <?php paintops_enqueue_script('job-photos-pair'); ?>
                </form>
            <?php endif; ?>
        </div>

        <?php
        // After a Before/After action the synchronous POST reloads the whole
        // app page and the browser lands back at the very top, far above this
        // box, which is what made the flow feel broken (the success notice and
        // the "next step" card were off-screen). paintops_process_job_photos_post()
        // sets $GLOBALS['paintops_ba_scroll'] on success; here we scroll the
        // browser to the relevant section and give the target a brief flash so
        // it is obvious what just happened and what to do next.
        $baScroll = isset($GLOBALS['paintops_ba_scroll']) ? (string) $GLOBALS['paintops_ba_scroll'] : '';
        if ($baScroll !== ''):
            $baTargetId = $baScroll === 'pairs'
                ? 'paintops-ba-pairs'
                : ($baScroll === 'waiting' ? 'paintops-ba-waiting' : 'paintops-before-after');
            paintops_enqueue_style('job-photos-ba-flash');
            ob_start(); ?>
        (function() {
            var target = document.getElementById(<?php echo wp_json_encode($baTargetId); ?>);
            if (!target) { return; }
            // Let the page settle a tick before scrolling (fonts/images).
            setTimeout(function() {
                target.scrollIntoView({ behavior: 'smooth', block: 'center' });
                var flash = target.querySelector('.pp-ba-just-added') || target;
                flash.classList.add('pp-ba-flash');
                setTimeout(function() { flash.classList.remove('pp-ba-flash'); }, 2300);
            }, 80);
        })();
        <?php paintops_add_inline_script((string) ob_get_clean()); ?>
        <?php endif; ?>
    <?php if (!$bare): ?>
    </div>
    <?php endif; ?>
    <?php
}

/**
 * Process a receipt upload/delete POST for a job, same idea as
 * paintops_process_job_photos_post() above, but for the receipts shown
 * inside the Job Financials box. Kept separate since Financials has its
 * own nonce/capability check (Administrator + Pro only, via
 * paintops_user_can_view_financials()), stricter than the general
 * paintops_user_can_manage() check used for ordinary Job Photos.
 */
function paintops_process_job_receipts_post(int $job_id): string {
    // Gated the same way as paintops_render_worker_add_receipt_box() in
    // job-financials-addon.php: staff, OR a Painter/Helper specifically
    // assigned to this job (see paintops_user_can_add_job_receipt()),
    // NOT paintops_user_can_view_financials() (Administrator-only), since
    // this box never shows gross/profit and an assigned worker needs to
    // be able to upload/replace their own receipt photos here too.
    if ($job_id <= 0 || !function_exists('paintops_user_can_add_job_receipt') || !paintops_user_can_add_job_receipt($job_id)) {
        return '';
    }

    if (isset($_POST['paintops_upload_job_receipt'])) {
        $nonce_ok = isset($_POST['paintops_job_receipts_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_receipts_nonce'])), 'paintops_job_receipts_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $expense_id = (int) ($_POST['job_receipt_expense_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable

        if (!isset($_FILES['job_receipt_file'])) {
            return '<div class="paintops-notice is-error">The server never received a file field at all, this points to a hosting-level limit (post_max_size) or a form/enctype problem, not the photo itself.</div>';
        }

        // 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
        $file = paintops_sanitize_upload($_FILES['job_receipt_file']);

        if (empty($file['name'])) {
            return '<div class="paintops-notice is-error">Please choose a receipt photo to upload.</div>';
        }

        $result = paintops_handle_job_photo_upload($job_id, $file, 'receipt', $expense_id);

        if (is_wp_error($result)) {
            return '<div class="paintops-notice is-error">' . wp_kses($result->get_error_message(), ['br' => []]) . ' (Details: name=' . esc_html(sanitize_file_name((string) $file['name'])) . ', size=' . esc_html((string) (int) ($file['size'] ?? 0)) . ' bytes, php error code=' . esc_html((string) (int) ($file['error'] ?? 0)) . ', expense_id=' . esc_html((string) (int) $expense_id) . ')</div>';
        }

        return '<div class="paintops-notice is-success">Receipt uploaded (photo row #' . esc_html((string) $result) . ').</div>';
    }

    if (isset($_POST['paintops_delete_job_receipt'])) {
        $nonce_ok = isset($_POST['paintops_job_receipts_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_receipts_nonce'])), 'paintops_job_receipts_' . $job_id);

        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }

        $photo_id = (int) $_POST['paintops_delete_job_receipt'];

        if (!paintops_user_can_delete_job_photo($photo_id, $job_id)) {
            return '<div class="paintops-notice is-error">You can only delete a receipt you uploaded yourself, ask an Administrator if it needs to come down.</div>';
        }

        if (paintops_delete_job_photo($photo_id, $job_id)) {
            return '<div class="paintops-notice is-success">Receipt deleted.</div>';
        }

        return '<div class="paintops-notice is-error">Could not delete that receipt.</div>';
    }

    // Delete an entire expense RECORD, not just its attachment (Mike
    // 2026-07-23: duplicate expenses had no way out, only the receipt
    // image could be removed). Staff-only, deleting money data is above
    // an assigned worker's receipt permission, and it takes any attached
    // receipt images down with it so no orphaned photos linger.
    if (isset($_POST['paintops_delete_job_expense'])) {
        $nonce_ok = isset($_POST['paintops_job_receipts_nonce'])
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_receipts_nonce'])), 'paintops_job_receipts_' . $job_id);
        if (!$nonce_ok) {
            return '<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>';
        }
        if (!function_exists('paintops_user_can_manage') || !paintops_user_can_manage()) {
            return '<div class="paintops-notice is-error">Only staff can remove an expense record, ask the office if this one needs to go.</div>';
        }
        global $wpdb;
        $expense_id = (int) $_POST['paintops_delete_job_expense'];
        $owned = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM %i WHERE id = %d AND job_id = %d", paintops_table('job_expenses'), $expense_id, $job_id));
        if ($expense_id <= 0 || !$owned) {
            return '<div class="paintops-notice is-error">That expense could not be found on this job.</div>';
        }
        foreach (paintops_get_job_photos($job_id, 'receipt', $expense_id) as $ex_photo) {
            paintops_delete_job_photo((int) $ex_photo['id'], $job_id);
        }
        $wpdb->delete(paintops_table('job_expenses'), ['id' => $expense_id, 'job_id' => $job_id], ['%d', '%d']);
        return '<div class="paintops-notice is-success">Expense removed, along with its receipt image.</div>';
    }

    return '';
}

/**
 * Read-only expenses list + total for the Job Financials box. Used to
 * show a plain summary of every expense already logged (via the "Manage
 * or Add Expenses" page, ?jobAddReceipt=X, see
 * paintops_save_worker_expense_receipt() in job-financials-addon.php),
 * with a link to that same page for adding a new one or reviewing/
 * photographing an existing one. Expenses themselves are no longer
 * editable from here, see paintops_render_job_financial_box(), which used to
 * have its own Expenses table before Mike's call that having the same
 * data editable in two places was redundant.
 *
 * $show_costs=false (used by the worker-facing Add Receipt page itself,
 * so someone looking at their own submitted expenses doesn't also see
 * dollar amounts/vendor if they're not otherwise allowed to see job
 * financials) hides Vendor/Category/Amount and the Total row, just
 * "Expense #1", etc., same privacy boundary this box always had.
 */
function paintops_render_job_receipts_box(int $job_id, bool $show_costs = true, bool $show_manage_link = true, bool $show_header = true): void {
    global $wpdb;

    $expenses = $wpdb->get_results($wpdb->prepare(
        "SELECT id, expense_date, vendor, category, amount FROM %i WHERE job_id = %d ORDER BY expense_date ASC, id ASC", paintops_table('job_expenses'), $job_id), ARRAY_A) ?: [];

    $total = 0.0;
    foreach ($expenses as $expense) {
        $total += (float) ($expense['amount'] ?? 0);
    }
    ?>
    <div class="cs-financial-box" style="margin-top:16px;">
        <?php if ($show_header): ?><div class="job-form-section">Expenses</div><?php endif; ?>
        <?php if ($show_manage_link): ?>
            <p class="description" style="margin-top:0;">Expenses and their receipts are added from the job's Add Expense page, use the Add Expense action below.</p>
        <?php endif; ?>

        <?php if (!$expenses): ?>
            <p style="color:#777;">No expenses logged yet.</p>
        <?php else: ?>
            <table class="cs-fin-table">
                <thead>
                    <tr>
                        <th>Date</th>
                        <?php if ($show_costs): ?>
                            <th>Vendor</th>
                            <th>Category</th>
                            <th>Amount</th>
                        <?php else: ?>
                            <th>Item</th>
                        <?php endif; ?>
                        <th>Receipt</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($expenses as $expense_index => $expense): ?>
                        <?php
                        $expense_id = (int) $expense['id'];
                        $receipts = paintops_get_job_photos($job_id, 'receipt', $expense_id);
                        ?>
                        <tr>
                            <td><?php echo esc_html($expense['expense_date'] ?? ''); ?></td>
                            <?php if ($show_costs): ?>
                                <td><?php echo esc_html($expense['vendor'] ?: '-'); ?></td>
                                <td><?php echo esc_html($expense['category'] ?: '-'); ?></td>
                                <td><?php echo esc_html(paintops_currency_symbol()) . number_format((float) $expense['amount'], 2); ?></td>
                            <?php else: ?>
                                <td>Expense #<?php echo esc_html((string) ($expense_index + 1)); ?></td>
                            <?php endif; ?>
                            <td>
                                <?php if ($receipts): ?>
                                    <div style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
                                        <a href="<?php echo esc_url($receipts[0]['full_url']); ?>" target="_blank" rel="noopener noreferrer" class="button button-small">View</a>
                                        <?php if (paintops_can_current_user_delete_upload((int) $receipts[0]['uploaded_by'])): ?>
                                            <form method="post" enctype="multipart/form-data" onsubmit="return confirm('Delete this receipt?');" style="margin:0;">
                                                <?php wp_nonce_field('paintops_job_receipts_' . $job_id, 'paintops_job_receipts_nonce'); ?>
                                                <button type="submit" name="paintops_delete_job_receipt" value="<?php echo esc_attr((string) $receipts[0]['id']); ?>" class="button button-small">Delete</button>
                                            </form>
                                        <?php endif; ?>
                                    </div>
                                <?php elseif (function_exists('paintops_user_can_add_job_receipt') && paintops_user_can_add_job_receipt($job_id)): ?>
                                    <form method="post" enctype="multipart/form-data" style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
                                        <?php wp_nonce_field('paintops_job_receipts_' . $job_id, 'paintops_job_receipts_nonce'); ?>
                                        <input type="hidden" name="job_receipt_expense_id" value="<?php echo esc_attr((string) $expense_id); ?>">
                                        <?php paintops_render_photo_picker_input('job_receipt_file', '', false, false, false, true); ?>
                                        <button type="submit" name="paintops_upload_job_receipt" value="1" class="button button-small">Upload</button>
                                    </form>
                                <?php else: ?>
                                    <span style="color:#aaa;">None</span>
                                <?php endif; ?>
                                <?php if (function_exists('paintops_user_can_manage') && paintops_user_can_manage()): ?>
                                    <?php // Removes the RECORD, not just the image (Mike
                                          // 2026-07-23: duplicates were stuck). ?>
                                    <form method="post" onsubmit="return confirm('Remove this expense record (and its receipt image, if any)? This cannot be undone.');" style="margin:6px 0 0;">
                                        <?php wp_nonce_field('paintops_job_receipts_' . $job_id, 'paintops_job_receipts_nonce'); ?>
                                        <button type="submit" name="paintops_delete_job_expense" value="<?php echo esc_attr((string) $expense_id); ?>" class="button button-small" style="color:#b32d2e;">Remove Expense</button>
                                    </form>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
                <?php if ($show_costs): ?>
                    <tfoot>
                        <tr>
                            <td colspan="3" style="text-align:right;"><strong>Total</strong></td>
                            <td colspan="2"><strong><?php echo esc_html(paintops_currency_symbol()) . number_format($total, 2); ?></strong></td>
                        </tr>
                    </tfoot>
                <?php endif; ?>
            </table>
        <?php endif; ?>

        <?php if ($show_costs && $show_manage_link): ?>
            <?php // Same icon-over-label tile as the Job View quick actions
                  // (Mike 2026-07-22), not a highlighted button, so it can't
                  // be mistaken for this page's Save. The fixed column width
                  // stops a lone tile stretching across the grid. On the
                  // Financials page, .cs-addexpense-action is watched by the
                  // unsaved-work confirm (see the page script there). ?>
            <div class="pv-quick-actions pv-quick-actions-tiles" style="grid-template-columns:repeat(auto-fill, 120px);margin:14px 0 0;">
                <a href="<?php echo esc_url(paintops_admin_url(['jobAddReceipt' => $job_id])); ?>" class="cs-addexpense-action"><span class="dashicons dashicons-media-spreadsheet" aria-hidden="true"></span>Add Expense</a>
            </div>
        <?php endif; ?>
    </div>
    <?php
    // Only needed now that a row missing a receipt can render its own
    // upload picker above, both functions guard themselves against
    // printing twice per page, so this is safe even when Job Photos'
    // own upload box already printed them earlier on the same page.
    paintops_echo_photo_compress_script();
    paintops_echo_photo_picker_script();
}

/**
 * Read-only-except-for-delete Job Photos thumbnail strip for the job
 * view page, mirrors paintops_render_paint_colors_list()'s "don't show
 * an empty section" convention. Not shown to painters/helpers on the
 * worker-facing view; gated the same way as the rest of the staff-only
 * detail sections.
 *
 * Delete was originally left out of this preview on the assumption that
 * "Add Photos" (the full paintops_render_job_photos_box() page) was
 * where anyone would go to manage photos, in practice, this thumbnail
 * strip is what people actually look at on the job page itself, callback
 * visits included, and having no way to remove a photo from right there
 * was a real gap, not a deliberate restriction. The delete form below
 * posts to the same ?jobPhotos= page/handler paintops_render_job_photos_box()
 * already uses (paintops_process_job_photos_post()), rather than
 * duplicating that logic here.
 */
function paintops_render_job_photos_list(int $job_id): string {
    $photos = paintops_get_job_photos($job_id, 'photo');

    if (!$photos) {
        return '';
    }

    $can_manage = function_exists('paintops_user_can_manage_job_photos') && paintops_user_can_manage_job_photos($job_id);
    $delete_action = function_exists('paintops_admin_url') ? paintops_admin_url(['jobPhotos' => $job_id]) : '';

    ob_start();
    ?>
    <div class="paintops-job-photos">
        <h3 style="margin-bottom:6px;">Job Notes &amp; Photos <?php if ($delete_action !== ''): ?><a href="<?php echo esc_url($delete_action); ?>" style="font-weight:400;font-size:13px;">View all &amp; add &rarr;</a><?php endif; ?></h3>
        <div style="display:flex;flex-wrap:wrap;gap:8px;">
            <?php foreach ($photos as $photo): ?>
                <?php
                $hoverBits = [];
                if ($photo['taken_at_display'] !== '') { $hoverBits[] = 'Taken ' . $photo['taken_at_display']; }
                if (!empty($photo['notes'])) { $hoverBits[] = $photo['notes']; }
                $hoverTitle = implode(', ', $hoverBits);
                ?>
                <div style="width:100px;">
                    <a href="<?php echo esc_url($photo['full_url']); ?>" target="_blank" rel="noopener noreferrer">
                        <img src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" title="<?php echo esc_attr($hoverTitle); ?>" style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
                    </a>
                    <?php if (!empty($photo['notes'])): ?>
                        <p style="font-size:11px;color:#777;margin:3px 0 0;line-height:1.3;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;"><?php echo esc_html($photo['notes']); ?></p>
                    <?php endif; ?>
                    <?php if ($can_manage && $delete_action !== '' && paintops_can_current_user_delete_upload((int) $photo['uploaded_by'])) : ?>
                        <form method="post" action="<?php echo esc_url($delete_action); ?>" onsubmit="return confirm('Delete this photo?');" style="margin-top:4px;">
                            <?php wp_nonce_field('paintops_job_photos_' . $job_id, 'paintops_job_photos_nonce'); ?>
                            <button type="submit" name="paintops_delete_job_photo" value="<?php echo esc_attr((string) $photo['id']); ?>" class="button button-small" style="width:100%;">Delete</button>
                        </form>
                    <?php endif; ?>
                </div>
            <?php endforeach; ?>
        </div>
    </div>
    <?php
    return ob_get_clean();
}