<?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 - Offline support (PWA app shell, tier 1)
 *
 * Field painters lose cell signal on job sites. This turns the PaintOps
 * app into an installable Progressive Web App with a service worker so
 * that:
 *   1. The app "shell" (layout, styles, icons) and any job page a
 *      painter has already opened stay available with no signal. They
 *      can still read the address, scope, paint colors, and notes.
 *   2. The app can be added to a phone's home screen and opened like a
 *      native app (standalone window, no browser chrome).
 *   3. An "offline" banner tells them when they are looking at saved
 *      data and that changes cannot be saved until they reconnect.
 *
 * Read-focused: painters can read the app shell and any job page offline,
 * and today's scheduled jobs are pre-cached (tier 2) so they are on the
 * phone even if never opened. Capturing hours, expenses, or photos while
 * offline and syncing later (tier 3) is a separate, larger effort and is
 * NOT attempted here: the offline banner is honest that saving needs a
 * connection.
 *
 * How the service worker gets root scope
 * --------------------------------------
 * A service worker can only control pages at or below the path it is
 * served from. The app lives at the site's /paintops/ page, but plugin
 * files physically live deep under /wp-content/plugins/..., which could
 * never control /paintops/. So instead of serving the worker as a static
 * plugin file, we serve it from a virtual URL at the site root
 * (home_url('/paintops-sw.js')) by intercepting that request in
 * template_redirect. WordPress routes any not-a-real-file URL through
 * index.php, so this virtual path reaches us. Same trick for the web app
 * manifest. Nothing secret is served this way (the worker and manifest
 * are the same for everyone), so serving them to logged-out visitors is
 * fine.
 */

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

/**
 * Path (relative to the domain) that the app page lives at, e.g.
 * "/paintops/". The service worker only actively caches navigations
 * under this path so it never starts caching the marketing site or
 * wp-admin, even though it technically has whole-origin scope.
 */
function paintops_pwa_app_path(): string {
    $path = wp_parse_url(paintops_app_url(), PHP_URL_PATH);
    if (!is_string($path) || $path === '') {
        $path = '/';
    }
    if (substr($path, -1) !== '/') {
        $path .= '/';
    }
    return $path;
}

/**
 * URL the service worker script is served from.
 *
 * Served through admin-ajax.php (a fixed, always-present real path)
 * rather than an invented root URL like /paintops-sw.js. An invented URL
 * only reaches WordPress when pretty permalinks are on (the rewrite that
 * funnels not-a-real-file URLs to index.php); on a "Plain" permalinks
 * site the web server 404s it before PHP ever runs, and the whole
 * offline feature silently never initializes. admin-ajax works on every
 * install regardless of permalink settings. The catch, that a worker can
 * only control pages at or below its own path and admin-ajax lives under
 * /wp-admin/, is handled by the Service-Worker-Allowed header the script
 * is served with (see paintops_pwa_output_service_worker()).
 */
function paintops_pwa_sw_url(): string {
    return add_query_arg('action', 'paintops_sw', admin_url('admin-ajax.php'));
}

/** URL the web app manifest is served from (admin-ajax, permalink-independent). */
function paintops_pwa_manifest_url(): string {
    return add_query_arg('action', 'paintops_manifest', admin_url('admin-ajax.php'));
}

/** URL the today's-jobs pre-cache list is served from (admin-ajax, logged-in only). */
function paintops_pwa_precache_url(): string {
    return add_query_arg('action', 'paintops_precache_list', admin_url('admin-ajax.php'));
}

/**
 * URL of a PWA icon asset shipped with the plugin, stamped with the
 * plugin version. The version query means a plugin update changes every
 * icon URL, so browsers, the service worker cache, and the installed
 * app all fetch the new artwork instead of a stale cached copy.
 */
function paintops_pwa_icon_url(string $file): string {
    return add_query_arg('v', PAINTOPS_VERSION, plugins_url('assets/pwa/' . $file, PAINTOPS_PLUGIN_FILE));
}

/**
 * Is the current front-end request the PaintOps app page? Mirrors the
 * detection used by paintops_send_nocache_headers_for_app(): the app is
 * whichever singular page embeds the [paintops_app] shortcode.
 */
function paintops_pwa_is_app_page(): bool {
    if (is_admin() || !is_singular()) {
        return false;
    }
    $post = get_post();
    return $post && has_shortcode((string) $post->post_content, 'paintops_app');
}

/*
 * Serve the worker and manifest through admin-ajax.php. Both are public
 * and identical for everyone, so the nopriv variants are registered too;
 * the manifest in particular is often fetched by the browser without
 * credentials. Each handler sets its own Content-Type and exits.
 */
add_action('wp_ajax_paintops_sw', 'paintops_pwa_output_service_worker');
add_action('wp_ajax_nopriv_paintops_sw', 'paintops_pwa_output_service_worker');
add_action('wp_ajax_paintops_manifest', 'paintops_pwa_output_manifest');
add_action('wp_ajax_nopriv_paintops_manifest', 'paintops_pwa_output_manifest');

/**
 * Return today's job-view URLs for the current user as JSON, for the
 * service worker to pre-cache (tier 2). A manager gets every job on
 * today's schedule; a painter gets only the jobs they are assigned to.
 * Logged-in only, since the list is user-specific (no nopriv variant),
 * and capped so a busy day cannot balloon the pre-cache.
 */
function paintops_pwa_precache_list(): void {
    if (!is_user_logged_in()) {
        wp_send_json(['urls' => []]);
    }

    global $wpdb;
    $today = gmdate('Y-m-d', current_time('timestamp'));
    $jobs_table = paintops_table('jobs');

    // Same "spans today" logic as the Today's Crew widget and calendar:
    // a Job occupies reqDays, a Quote is a single day.
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT id, reqWorkers FROM %i
         WHERE TO_DAYS(DATE_ADD(CONCAT(startYear, '-', LPAD(startMonth, 2, '0'), '-', LPAD(startDay, 2, '0')), INTERVAL IF(jobType = 2, GREATEST(reqDays, 1) - 1, 0) DAY)) >= TO_DAYS(%s)
           AND TO_DAYS(CONCAT(startYear, '-', LPAD(startMonth, 2, '0'), '-', LPAD(startDay, 2, '0'))) <= TO_DAYS(%s)
         ORDER BY startTime ASC", $jobs_table, $today, $today));

    $is_manager = function_exists('paintops_user_can_manage') && paintops_user_can_manage();
    $uid = (int) get_current_user_id();

    $urls = [];
    foreach ((array) $rows as $row) {
        if (!$is_manager) {
            $worker_ids = function_exists('paintops_parse_worker_ids')
                ? array_map('intval', paintops_parse_worker_ids($row->reqWorkers))
                : [];
            if (!in_array($uid, $worker_ids, true)) {
                continue; // Not this painter's job, skip.
            }
        }
        $urls[] = paintops_app_url(['viewEid' => (int) $row->id]);
        if (count($urls) >= 50) {
            break; // Sanity cap on the pre-cache size.
        }
    }

    wp_send_json(['urls' => $urls]);
}
add_action('wp_ajax_paintops_precache_list', 'paintops_pwa_precache_list');

/** Output the web app manifest JSON and exit. */
function paintops_pwa_output_manifest(): void {
    $manifest = [
        'name'             => 'PaintOps',
        'short_name'       => 'PaintOps',
        'description'      => 'Quotes, schedules, and job details for the field.',
        'start_url'        => paintops_app_url(),
        'scope'            => paintops_pwa_app_path(),
        'display'          => 'standalone',
        'orientation'      => 'portrait-primary',
        'background_color' => '#ffffff',
        'theme_color'      => '#2f5f7a',
        'icons'            => [
            [
                'src'   => paintops_pwa_icon_url('icon-192.png'),
                'sizes' => '192x192',
                'type'  => 'image/png',
                'purpose' => 'any',
            ],
            [
                'src'   => paintops_pwa_icon_url('icon-512.png'),
                'sizes' => '512x512',
                'type'  => 'image/png',
                'purpose' => 'any',
            ],
            [
                'src'   => paintops_pwa_icon_url('icon-maskable-512.png'),
                'sizes' => '512x512',
                'type'  => 'image/png',
                'purpose' => 'maskable',
            ],
        ],
    ];

    // Be explicit about a clean 200 so the browser always accepts the file.
    status_header(200);
    nocache_headers();
    header('Content-Type: application/manifest+json; charset=utf-8');
    echo wp_json_encode($manifest);
    exit;
}

/**
 * Output the service worker JavaScript and exit.
 *
 * The JS is kept in a nowdoc (no PHP interpolation) so its own `$` and
 * template literals are untouched; per-site values are swapped in with a
 * placeholder replace afterward.
 */
function paintops_pwa_output_service_worker(): void {
    $icons = [
        paintops_pwa_icon_url('icon-192.png'),
        paintops_pwa_icon_url('icon-512.png'),
        paintops_pwa_icon_url('icon-maskable-512.png'),
        paintops_pwa_icon_url('apple-touch-icon.png'),
        paintops_pwa_icon_url('icon-32.png'),
    ];

    $replacements = [
        '__VERSION__'   => PAINTOPS_VERSION,
        '__APP_PATH__'  => paintops_pwa_app_path(),
        '__START_URL__' => paintops_app_url(),
        '__ICONS__'     => wp_json_encode($icons),
    ];

    $js = paintops_pwa_service_worker_source();
    $js = strtr($js, $replacements);

    // Be explicit about a clean 200 so the browser always accepts the worker.
    status_header(200);
    nocache_headers();
    header('Content-Type: application/javascript; charset=utf-8');
    // Lets the worker, though served from under /wp-admin/, claim scope up
    // to the site root so it can control the /paintops/ app page.
    header('Service-Worker-Allowed: /');
    // This response is application/javascript (the service worker file),
    // not HTML: the source is the plugin's own static asset and every
    // per-site value swapped in above is wp_json_encode()d or a
    // same-site URL. HTML escaping would corrupt the script.
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS document; values injected via wp_json_encode, HTML escaping does not apply
    echo $js;
    exit;
}

/** The raw service worker source (placeholders swapped by the caller). */
function paintops_pwa_service_worker_source(): string {
    // The worker's JavaScript lives in its own static file
    // (assets/js/paintops-sw-src.js); this endpoint reads it and swaps in
    // the per-site placeholder values before serving it as
    // application/javascript. Keeping the JS in a real .js file keeps
    // script code out of PHP strings entirely.
    $path = plugin_dir_path(PAINTOPS_PLUGIN_FILE) . 'assets/js/paintops-sw-src.js';
    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading the plugin's own bundled asset from disk, not a remote resource
    $src = file_get_contents($path);
    return is_string($src) ? $src : '';
}

/**
 * In the app page <head>: link the manifest, set the install/appearance
 * meta tags, and point iOS at the home-screen icon. Priority 5 so it
 * lands early in <head>.
 */
function paintops_pwa_head_tags(): void {
    if (!paintops_pwa_is_app_page()) {
        return;
    }
    ?>
<link rel="manifest" href="<?php echo esc_url(paintops_pwa_manifest_url()); ?>">
<meta name="theme-color" content="#2f5f7a">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PaintOps">
<link rel="apple-touch-icon" href="<?php echo esc_url(paintops_pwa_icon_url('apple-touch-icon.png')); ?>">
<?php
}
add_action('wp_head', 'paintops_pwa_head_tags', 5);

/**
 * On the app page: enqueue the PWA client (assets/js/pwa-app.js: service
 * worker registration, online/offline banner logic, logout cache-clear,
 * install chip) and its styles (assets/css/pwa-app.css). Per-site config
 * (SW/pre-cache URLs, scope) rides along as an inline before-fragment.
 * The app-page check works here because wp_enqueue_scripts fires after
 * the main query is set up.
 */
function paintops_pwa_enqueue_assets(): void {
    if (!paintops_pwa_is_app_page()) {
        return;
    }
    $cfg = [
        'swUrl'       => esc_url(paintops_pwa_sw_url()),
        'precacheUrl' => esc_url(paintops_pwa_precache_url()),
        'scope'       => esc_js(wp_parse_url(home_url('/'), PHP_URL_PATH) ?: '/'),
    ];
    paintops_enqueue_style('pwa-app');
    paintops_enqueue_script('pwa-app', 'var paintopsPwaCfg = ' . wp_json_encode($cfg) . ';');
}
add_action('wp_enqueue_scripts', 'paintops_pwa_enqueue_assets');

/**
 * In the app page footer: the offline banner and "Install app" chip
 * markup that assets/js/pwa-app.js (enqueued above) reads and drives.
 * Priority 10 (was 20 when the JS was printed inline right after this
 * markup): the enqueued script now prints with the footer scripts at
 * priority 20, and it looks these elements up as it runs, so the markup
 * must be on the page first.
 */
function paintops_pwa_footer(): void {
    if (!paintops_pwa_is_app_page()) {
        return;
    }
    // Some render paths (a few estimates/financials routes) reached here
    // without the head enqueue having fired, printing this markup with no
    // stylesheet: the banner then showed a false offline message on every
    // load (found 2026-08-31 walkthrough). Two defenses: enqueue here too
    // (late styles still print in the footer, and enqueues are idempotent)
    // and inline display:none so the markup is invisible until the JS
    // toggles it via the stylesheet's !important is-visible rules.
    paintops_pwa_enqueue_assets();
    ?>
<div id="paintops-offline-banner" role="status" aria-live="polite" style="display:none">
  You are offline. Showing saved job info; changes cannot be saved until you reconnect.
</div>
<button id="paintops-install-chip" type="button" aria-label="Install PaintOps app" style="display:none">
  <span>Install PaintOps</span><span class="pi-x" data-role="dismiss">&times;</span>
</button>
<?php
}
add_action('wp_footer', 'paintops_pwa_footer', 10);
