<?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 -- the two remaining date() calls (checkin_at/checkout_at columns) are the WRITE side of a naive date()/strtotime() round-trip pair used to store local wall-clock strings; every place a resulting pseudo-epoch is actually RENDERED for a human uses gmdate(), per the site-wide TIMEZONE convention (see the 2026-07-24 gmdate sweep, v1.0.235): current_time('timestamp') pseudo-epochs render with gmdate(), never date(), because PHP's runtime default timezone on this server is NOT UTC.
/**
 * PaintOps - Job Check-In / Check-Out
 *
 * Tap "Check In" when you arrive at a job, "Check Out" when you leave,
 * and the elapsed time is logged automatically, no more typing hours
 * into the Financials box after the fact. Sessions live in their own
 * cs_job_checkins table (the punch-clock record: exact in/out times),
 * but the HOURS land in the existing cs_job_worker_hours table on
 * checkout, deliberately: that table is what paintops_job_financial_summary()
 * and every profit report already read, so labor cost, profit-per-hour,
 * and the rankings all pick up checked-in time with zero changes, and
 * staff can still see/correct any logged session in the job's Manage
 * Financials box, exactly like a manually-entered row.
 *
 * Who can punch the clock: anyone who can view the job, staff, or a
 * Painter/Helper assigned to it (paintops_user_can_view_job()). That was
 * Mike's call ("assigned workers + staff"), and it means the button
 * simply appears on the job view page every crew member already has
 * open on their phone.
 *
 * Deliberately NOT Pro-gated, same reasoning as Paint Colors: punching
 * in and out is operational info needed to run the job day-to-day, not
 * a profitability feature. (On a free site the hours rows simply sit
 * unused, since the Financials/profit surfaces that read them are Pro.)
 *
 * Forgotten checkouts: logged hours are capped (12h by default, see
 * paintops_checkin_max_hours()) so someone who forgets to check out on
 * Friday can't silently log a 60-hour "session" and poison the job's
 * labor cost. The capped row says so in its notes, and staff can adjust
 * it in Financials & Profitability like any other hours row.
 */

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

/**
 * Longest session that will ever be LOGGED as hours from a single
 * check-in, in hours. The punch-clock row itself keeps the real in/out
 * times regardless, only the hours written to cs_job_worker_hours are
 * capped. Filterable for the odd business that genuinely runs longer
 * shifts.
 */
function paintops_checkin_max_hours(): float {
    return (float) apply_filters('paintops_checkin_max_hours', 12.0);
}

/**
 * Whether the current user may check in/out of this job: staff, or a
 * Painter/Helper assigned to it. Same gate as viewing the job page the
 * button lives on, so the button can never appear somewhere the user
 * couldn't already be.
 */
function paintops_user_can_checkin(int $job_id): bool {
    // Feature switch (PaintOps > Business > Time Clock). Off = the Time
    // Clock is disabled shop-wide: no box on the job view, the live Check
    // In / Out POST is refused, and the offline-sync endpoint (which
    // re-checks this same function) rejects queued punches. One gate for all.
    if (function_exists('paintops_timeclock_enabled') && !paintops_timeclock_enabled()) {
        return false;
    }
    if ($job_id <= 0 || !is_user_logged_in()) {
        return false;
    }
    return function_exists('paintops_user_can_view_job') && paintops_user_can_view_job($job_id);
}

/**
 * The current user's open (not yet checked out) session on this job,
 * or null. One open session per user per job, Check In while one is
 * open is refused rather than silently stacking a second.
 */
function paintops_get_open_checkin(int $job_id, int $user_id): ?object {
    global $wpdb;

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

    $row = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM %i
         WHERE job_id = %d AND user_id = %d AND checkout_at IS NULL
         ORDER BY id DESC LIMIT 1", paintops_table('job_checkins'), $job_id, $user_id));

    return $row ?: null;
}

/**
 * Recent finished + open sessions on a job, newest first, for the
 * activity list under the button. Plain punch-clock info (who, when,
 * how long), deliberately no rates or costs here, so it's safe to
 * show every crew member who can see the job. Money stays behind
 * paintops_user_can_view_financials() in the Financials box.
 */
function paintops_get_job_checkins(int $job_id, int $limit = 8): array {
    global $wpdb;

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

    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM %i
         WHERE job_id = %d
         ORDER BY id DESC LIMIT %d", paintops_table('job_checkins'), $job_id, max(1, $limit)));

    return is_array($rows) ? $rows : [];
}

/**
 * Record a check-IN for this user at a specific moment ($in_unix, a Unix
 * timestamp). Shared by the live button (moment = now) and the offline
 * sync endpoint (moment = when the painter actually tapped, off-signal),
 * which is why the time is a parameter and not date() inside here.
 *
 * Idempotent: a sync retry carrying the same instant will not create a
 * second row (matched on the exact checkin_at), and an existing open
 * session is reported rather than stacking another.
 *
 * Returns ['status' => 'opened'|'already_open'|'duplicate'|'error',
 *          'checkin_at' => 'Y-m-d H:i:s'] (checkin_at absent on error).
 */
function paintops_checkin_apply_in(int $job_id, int $user_id, int $in_unix): array {
    global $wpdb;

    $table = paintops_table('job_checkins');
    $checkin_at = date('Y-m-d H:i:s', $in_unix);

    $dupe = $wpdb->get_var($wpdb->prepare(
        "SELECT id FROM %i WHERE job_id = %d AND user_id = %d AND checkin_at = %s LIMIT 1", $table, $job_id, $user_id, $checkin_at));
    if ($dupe) {
        return ['status' => 'duplicate', 'checkin_at' => $checkin_at];
    }

    $open = paintops_get_open_checkin($job_id, $user_id);
    if ($open) {
        return ['status' => 'already_open', 'checkin_at' => (string) $open->checkin_at];
    }

    $inserted = $wpdb->insert($table, [
        'job_id'     => $job_id,
        'user_id'    => $user_id,
        'checkin_at' => $checkin_at,
    ], ['%d', '%d', '%s']);

    if ($inserted === false) {
        if (function_exists('paintops_report_db_error')) {
            paintops_report_db_error('job check-in');
        }
        return ['status' => 'error'];
    }

    return ['status' => 'opened', 'checkin_at' => $checkin_at];
}

/**
 * Close this user's open session at a specific moment ($out_unix) and
 * log the hours, using the exact same math and hours-row shape the live
 * checkout has always used. Shared by the live button (now) and the
 * offline sync (tap time).
 *
 * Idempotent: with no open session there is nothing to close, so a retry
 * is a harmless no-op ('none').
 *
 * Returns ['status' => 'closed'|'closed_no_hours'|'none'|'error',
 *          'logged_hours','elapsed_hours','was_capped','in_unix'].
 */
function paintops_checkin_apply_out(int $job_id, int $user_id, int $out_unix): array {
    global $wpdb;

    $table = paintops_table('job_checkins');

    $open = paintops_get_open_checkin($job_id, $user_id);
    if (!$open) {
        return ['status' => 'none'];
    }

    $in_ts = strtotime((string) $open->checkin_at);
    if ($out_unix < $in_ts) {
        $out_unix = $in_ts; // Clock-skew guard: never log a negative session.
    }
    $elapsed_hours = max(0.01, round(($out_unix - $in_ts) / 3600, 2));

    $max_hours = paintops_checkin_max_hours();
    $was_capped = $elapsed_hours > $max_hours;
    $logged_hours = $was_capped ? $max_hours : $elapsed_hours;

    $updated = $wpdb->update($table, [
        'checkout_at' => date('Y-m-d H:i:s', $out_unix),
        'hours'       => $logged_hours,
        'was_capped'  => $was_capped ? 1 : 0,
    ], ['id' => (int) $open->id], ['%s', '%f', '%d'], ['%d']);

    if ($updated === false) {
        if (function_exists('paintops_report_db_error')) {
            paintops_report_db_error('job check-out');
        }
        return ['status' => 'error'];
    }

    // gmdate(), NOT date() or wp_date() (timezone bug fix, 2026-07-24,
    // see the v1.0.235 gmdate sweep): $in_ts / $out_unix are LOCAL
    // pseudo-epochs (callers pass current_time('timestamp'), and
    // strtotime() of the local-wall checkin_at strings yields the same
    // kind). wp_date() would add the site offset a second time. Naive
    // date() was ALSO wrong here: it assumed PHP's runtime default
    // timezone is UTC, which is false on this server (something else
    // overrides it), so date() applied a second shift and displayed
    // times 4 hours off. gmdate() formats the pseudo-epoch's numeric
    // value directly with no further tz conversion, which is exactly
    // what these already-shifted pseudo-epochs need.
    $notes = 'Check-in ' . gmdate('M j', $in_ts) . ', ' . gmdate('g:i a', $in_ts) . ' - ' . gmdate('g:i a', $out_unix);
    if ($was_capped) {
        $notes .= ' (ran ' . $elapsed_hours . 'h, logged hours capped at ' . $max_hours . 'h, forgotten checkout? Adjust in Financials & Profitability if needed.)';
    }

    $rate = function_exists('paintops_get_worker_hourly_rate') ? paintops_get_worker_hourly_rate($user_id) : 0.0;

    $hours_inserted = $wpdb->insert(paintops_table('job_worker_hours'), [
        'job_id'      => $job_id,
        'user_id'     => $user_id,
        'work_date'   => gmdate('Y-m-d', $in_ts),
        'hours'       => $logged_hours,
        'hourly_rate' => $rate,
        'notes'       => $notes,
        'created_by'  => $user_id,
    ], ['%d', '%d', '%s', '%f', '%f', '%s', '%d']);

    if ($hours_inserted === false && function_exists('paintops_report_db_error')) {
        paintops_report_db_error('job check-out hours insert');
    }

    return [
        'status'        => ($hours_inserted === false) ? 'closed_no_hours' : 'closed',
        'logged_hours'  => $logged_hours,
        'elapsed_hours' => $elapsed_hours,
        'was_capped'    => $was_capped,
        'in_unix'       => $in_ts,
    ];
}

/**
 * Handle a Check In / Check Out POST for this job. Returns a notice
 * string ('' when this request wasn't a check-in/out POST at all).
 *
 * Checkout is where the actual time-logging happens: the session row
 * gets its checkout_at/hours, and a matching row is inserted into
 * cs_job_worker_hours (work_date = the day the session STARTED, so an
 * overnight checkout still books the hours to the day worked). The
 * hours row's notes record the exact in/out times, which shows up in
 * the Financials & Profitability box so staff can see where each row came from.
 */
function paintops_process_job_checkin_post(int $job_id): string {
    global $wpdb;

    if ($job_id <= 0 || !paintops_user_can_checkin($job_id)) {
        return '';
    }

    $is_checkin = isset($_POST['paintops_job_checkin']);
    $is_checkout = isset($_POST['paintops_job_checkout']);

    if (!$is_checkin && !$is_checkout) {
        return '';
    }

    $nonce_ok = isset($_POST['paintops_job_checkin_nonce'])
        && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_checkin_nonce'])), 'paintops_job_checkin_' . $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>';
    }

    $user_id = get_current_user_id();

    if ($is_checkin) {
        // Live check-in happens "now"; the shared helper is the same code
        // the offline sync runs, just with a different moment.
        $r = paintops_checkin_apply_in($job_id, $user_id, current_time('timestamp'));

        if ($r['status'] === 'already_open') {
            return '<div class="paintops-notice is-error">You\'re already checked in to this job (since ' . esc_html(gmdate('g:i a', strtotime((string) $r['checkin_at']))) . '). Check out first.</div>';
        }
        if ($r['status'] === 'error') {
            return '<div class="paintops-notice is-error">Could not save the check-in, please try again.</div>';
        }

        return '<div class="paintops-notice is-success">Checked in at ' . esc_html(wp_date('g:i a')) . '. Have a good one, remember to check out when you leave.</div>';
    }

    // Check Out, "now" for the live button. The worker-hours row this
    // writes is indistinguishable from one staff typed into Manage
    // Financials (where it can also be corrected or removed).
    $r = paintops_checkin_apply_out($job_id, $user_id, current_time('timestamp'));

    if ($r['status'] === 'none') {
        return '<div class="paintops-notice is-error">You\'re not currently checked in to this job, so there\'s nothing to check out of. (If you double-tapped or refreshed, your earlier checkout already went through, see the list below.)</div>';
    }
    if ($r['status'] === 'error') {
        return '<div class="paintops-notice is-error">Could not save the check-out, please try again.</div>';
    }
    if ($r['status'] === 'closed_no_hours') {
        return '<div class="paintops-notice is-error">Checked out, but the hours row could not be saved, please add ' . esc_html(number_format((float) $r['logged_hours'], 2)) . ' hours manually in Financials &amp; Profitability.</div>';
    }

    $in_ts = (int) $r['in_unix'];
    $logged_hours = (float) $r['logged_hours'];
    $elapsed_hours = (float) $r['elapsed_hours'];
    $was_capped = (bool) $r['was_capped'];
    $max_hours = paintops_checkin_max_hours();

    $msg = 'Checked out. Logged <strong>' . esc_html(number_format($logged_hours, 2)) . ' hours</strong> for ' . esc_html(gmdate('M j', $in_ts)) . '.';
    if ($was_capped) {
        $msg .= ' That session ran ' . esc_html(number_format($elapsed_hours, 2)) . ' hours, so it was capped at ' . esc_html(number_format($max_hours, 2)) . ', looks like a checkout may have been forgotten. Staff can adjust it under Financials &amp; Profitability.';
    }

    return '<div class="paintops-notice is-success">' . $msg . '</div>';
}

/**
 * The Check In / Check Out box shown on the job view page (?viewEid=),
 * for staff and assigned workers alike. Handles its own POST first
 * (same inline pattern as timeoff.php/calendar.php), so wiring it into
 * the page is a single call. Returns '' for anyone who can't punch this
 * job's clock, so callers don't need their own permission check.
 */
function paintops_render_job_checkin_box(int $job_id): string {
    if (!paintops_user_can_checkin($job_id)) {
        return '';
    }

    $notice = paintops_process_job_checkin_post($job_id);
    $user_id = get_current_user_id();
    $open = paintops_get_open_checkin($job_id, $user_id);
    $recent = paintops_get_job_checkins($job_id, 8);

    ob_start();
    ?>
    <div class="cs-financial-box" style="margin-top:18px;">
        <h3 style="margin-top:0;">Time Clock</h3>
        <?php echo wp_kses($notice, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>

        <?php if ($open): ?>
            <?php
            $in_ts = strtotime((string) $open->checkin_at);
            $mins = max(0, (int) floor((current_time('timestamp') - $in_ts) / 60));
            $elapsed_label = floor($mins / 60) . 'h ' . ($mins % 60) . 'm';
            ?>
            <p style="margin:6px 0;">
                <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2a6f2a;margin-right:6px;"></span>
                <strong>Checked in</strong> since <?php echo esc_html(gmdate('g:i a', $in_ts)); ?>
                (<?php echo esc_html($elapsed_label); ?> so far).
            </p>
            <form method="post" style="margin:10px 0 0;" data-paintops-timeclock="<?php echo esc_attr((string) $job_id); ?>">
                <?php wp_nonce_field('paintops_job_checkin_' . $job_id, 'paintops_job_checkin_nonce'); ?>
                <button type="submit" name="paintops_job_checkout" value="1" class="paintops-btn paintops-btn-primary" style="min-width:200px;">Check Out</button>
            </form>
        <?php else: ?>
            <p style="color:#777;font-size:13px;margin:6px 0;">Tap Check In when you arrive and Check Out when you leave, your hours are logged to this job automatically.</p>
            <form method="post" style="margin:10px 0 0;" data-paintops-timeclock="<?php echo esc_attr((string) $job_id); ?>">
                <?php wp_nonce_field('paintops_job_checkin_' . $job_id, 'paintops_job_checkin_nonce'); ?>
                <button type="submit" name="paintops_job_checkin" value="1" class="paintops-btn paintops-btn-primary" style="min-width:200px;">Check In</button>
            </form>
        <?php endif; ?>

        <?php if ($recent): ?>
            <?php
            // Recent sessions can pile up on a busy job, so this history is
            // collapsed by default into a native <details> disclosure (the
            // count shows in the summary so it's clear there's history to
            // open). No JS: the caret rotates via the [open] CSS, which is
            // enqueued through the styles API (wp.org review 2026-07); was
            // a literal <style> block here (assets/css/job-checkin.css).
            paintops_enqueue_style('job-checkin');
            ?>
            <details class="paintops-tc-recent">
                <summary><span class="paintops-tc-caret" aria-hidden="true">&#9654;</span> Recent time on this job (<?php echo (int) count($recent); ?>)</summary>
            <table style="width:100%;max-width:520px;font-size:13px;border-collapse:collapse;margin-top:8px;">
                <?php foreach ($recent as $session): ?>
                    <?php
                    $s_in = strtotime((string) $session->checkin_at);
                    $s_user = get_userdata((int) $session->user_id);
                    // get_userdata() is false for a deleted account, same
                    // stale-worker guard used everywhere else in the plugin.
                    $s_name = $s_user ? $s_user->display_name : 'Former team member';
                    ?>
                    <tr style="border-bottom:1px solid #eee;">
                        <td style="padding:4px 8px 4px 0;"><?php echo esc_html($s_name); ?></td>
                        <td style="padding:4px 8px 4px 0;white-space:nowrap;"><?php echo esc_html(gmdate('M j', $s_in)); ?></td>
                        <td style="padding:4px 8px 4px 0;white-space:nowrap;">
                            <?php echo esc_html(gmdate('g:i a', $s_in)); ?> &middot;
                            <?php echo wp_kses($session->checkout_at ? esc_html(gmdate('g:i a', strtotime((string) $session->checkout_at))) : '<em>on the clock</em>', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
                        </td>
                        <td style="padding:4px 0;white-space:nowrap;text-align:right;">
                            <?php if ($session->checkout_at): ?>
                                <?php echo esc_html(number_format((float) $session->hours, 2)); ?> h<?php echo wp_kses(!empty($session->was_capped) ? ' <span style="color:#b00;" title="Session ran past the cap, hours were capped. Adjust in Financials &amp; Profitability.">(capped)</span>' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
            </table>
            <?php if (function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials()): ?>
                <p style="color:#777;font-size:12px;margin:8px 0 0;">Logged hours land in this job's <a href="<?php echo esc_url(paintops_admin_url(['jobFinancials' => $job_id])); ?>">Financials &amp; Profitability</a><?php echo wp_kses((function_exists('paintops_pro_active') && !paintops_pro_active()) ? '<sup class="pro-badge">Pro</sup>' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?> box, where any session can be corrected or removed.</p>
            <?php endif; ?>
            </details>
        <?php endif; ?>
    </div>
    <?php
    return (string) ob_get_clean();
}
