<?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.Security.NonceVerification.Recommended -- read-only GET routing and filter parameters (which screen/record to show); every state-changing handler verifies a nonce before writing.
/**
 * Included File: Calendar Feed (webcal/.ics subscribe link)
 *
 * One-way, read-only export of PaintOps jobs/quotes as a standard
 * iCalendar feed, so Google Calendar, Apple Calendar, and Outlook can
 * "Subscribe by URL" and show every job/quote on a phone's own calendar
 * app. Access is gated by an unguessable per-site token
 * (paintops_calendar_feed_token option), never a WP login -- calendar
 * apps poll a subscribed feed on their own schedule with no user
 * present to click through a login prompt, so a token-in-the-URL
 * scheme is the standard approach for this kind of feed.
 *
 * Deliberately one-way (PaintOps -> calendar app) and not real-time.
 * A full two-way Google Calendar sync (push AND pull, with conflict
 * handling) needs OAuth, Google's app-verification process, and
 * per-site refresh-token storage -- a much bigger, separate project
 * that was deliberately scoped out in favor of this simpler, dependency
 * -free version.
 */

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

/**
 * Get (or lazily generate) the unguessable token used in the calendar
 * feed URL. One token per site -- not per-job or per-user -- since this
 * feed represents the whole business's schedule.
 */
function paintops_calendar_feed_token(): string {
    $token = get_option('paintops_calendar_feed_token');

    if (!empty($token)) {
        return (string) $token;
    }

    $token = wp_generate_password(40, false, false);
    update_option('paintops_calendar_feed_token', $token);

    return $token;
}

/**
 * Invalidate the current feed link and generate a new one -- for when a
 * link has leaked, or someone just wants to cut off an old subscription.
 */
function paintops_regenerate_calendar_feed_token(): string {
    $token = wp_generate_password(40, false, false);
    update_option('paintops_calendar_feed_token', $token);
    return $token;
}

/**
 * The URL to hand a user for "Subscribe by URL" in their calendar app.
 */
function paintops_calendar_feed_url(): string {
    return add_query_arg([
        'paintops_calendar_feed' => 1,
        'token'                  => paintops_calendar_feed_token(),
    ], home_url('/'));
}

/**
 * Escape a text value for use inside an iCalendar field, per RFC 5545
 * (backslash, semicolon, comma, and newlines all need escaping).
 */
function paintops_ics_escape(string $text): string {
    return str_replace(
        ['\\', ';', ',', "\r\n", "\n", "\r"],
        ['\\\\', '\\;', '\\,', '\\n', '\\n', '\\n'],
        $text
    );
}

/**
 * Fold a content line to <=75 octets per RFC 5545, with a CRLF and a
 * single leading space starting each continuation line.
 */
function paintops_ics_fold(string $line): string {
    if (strlen($line) <= 75) {
        return $line;
    }

    $chunks = [];
    while (strlen($line) > 75) {
        $chunks[] = substr($line, 0, 75);
        $line = substr($line, 75);
    }
    $chunks[] = $line;

    return implode("\r\n ", $chunks);
}

/**
 * Build one VEVENT block for a job or quote row. Mirrors the same
 * duration rules calendar.php's grid already uses: a Job (jobType 2)
 * spans reqDays (min 1), a Quote (jobType 1) is always a single day.
 */
function paintops_ics_event_for_job($job, string $site_host): string {
    $is_job = ((int) $job->jobType === 2);
    $duration_days = $is_job ? max(1, (int) $job->reqDays) : 1;

    $tz = wp_timezone();
    $start_time = trim((string) $job->startTime);
    $has_time = ($duration_days === 1) && preg_match('/^\d{1,2}:\d{2}$/', $start_time);

    $lines = [];
    $lines[] = 'BEGIN:VEVENT';
    $lines[] = 'UID:paintops-job-' . (int) $job->id . '@' . $site_host;
    $lines[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');

    if ($has_time) {
        [$h, $m] = array_map('intval', explode(':', $start_time));
        $start = new DateTime('now', $tz);
        $start->setDate((int) $job->startYear, (int) $job->startMonth, (int) $job->startDay);
        $start->setTime($h, $m, 0);

        // Jobs don't store an end time, only a start -- 2 hours is a
        // reasonable placeholder so the event has some visible width on
        // a day view rather than looking instantaneous.
        $end = clone $start;
        $end->modify('+2 hours');

        $start = (clone $start)->setTimezone(new DateTimeZone('UTC'));
        $end = $end->setTimezone(new DateTimeZone('UTC'));

        $lines[] = 'DTSTART:' . $start->format('Ymd\THis\Z');
        $lines[] = 'DTEND:' . $end->format('Ymd\THis\Z');
    } else {
        $start = new DateTime('now', $tz);
        $start->setDate((int) $job->startYear, (int) $job->startMonth, (int) $job->startDay);

        // DTEND is exclusive for all-day events, so +duration lands on
        // the day after the last day of the job.
        $end = clone $start;
        $end->modify('+' . $duration_days . ' days');

        $lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd');
        $lines[] = 'DTEND;VALUE=DATE:' . $end->format('Ymd');
    }

    $category = function_exists('paintops_get_category_details') ? (string) paintops_get_category_details((int) $job->jobCat, 0) : '';
    $cust_name = trim($job->custFirst . ' ' . $job->custLast);
    $label = $is_job ? 'Job' : 'Quote';

    $summary = trim($label . ': ' . trim($category . ' ' . $job->jobTitle));
    if ($cust_name !== '') {
        $summary .= ' - ' . $cust_name;
    }

    $address = trim($job->custServiceAddress . ', ' . $job->custServiceCity . ' ' . (string) ($job->custServiceState ?? '') . ' ' . $job->custServiceZip, ' ,');
    $address = preg_replace('/\s+/', ' ', $address); // collapse the extra space when custServiceState is blank

    $lines[] = paintops_ics_fold('SUMMARY:' . paintops_ics_escape($summary));

    if ($address !== '') {
        $lines[] = paintops_ics_fold('LOCATION:' . paintops_ics_escape($address));
    }

    $desc_parts = [];
    if (!empty($job->custPhone)) {
        $desc_parts[] = 'Phone: ' . $job->custPhone;
    }
    if (!empty($job->jobDesc)) {
        $desc_parts[] = wp_strip_all_tags(stripslashes($job->jobDesc));
    }
    if (!empty($desc_parts)) {
        $lines[] = paintops_ics_fold('DESCRIPTION:' . paintops_ics_escape(implode('\n', $desc_parts)));
    }

    $lines[] = 'STATUS:' . ((int) $job->completed === 1 ? 'CONFIRMED' : 'TENTATIVE');
    $lines[] = 'END:VEVENT';

    return implode("\r\n", $lines);
}

/**
 * Build the full .ics feed body: every job/quote from 60 days ago
 * through a year out. That window keeps the feed from growing forever
 * on an established site while still covering recent history and
 * everything scheduled for the foreseeable future.
 */
function paintops_build_calendar_ics(): string {
    global $wpdb;

    $jobs_table = paintops_table('jobs');
    $window_start = gmdate('Y-m-d', strtotime('-60 days'));
    $window_end = gmdate('Y-m-d', strtotime('+365 days'));

    $jobs = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM %i
         WHERE STR_TO_DATE(CONCAT(startYear,'-',startMonth,'-',startDay), '%%Y-%%c-%%e') BETWEEN %s AND %s
         ORDER BY startYear, startMonth, startDay
         LIMIT 1000", $jobs_table, $window_start, $window_end));

    $site_host = wp_parse_url(home_url(), PHP_URL_HOST) ?: 'paintops.local';

    $lines = [];
    $lines[] = 'BEGIN:VCALENDAR';
    $lines[] = 'VERSION:2.0';
    $lines[] = 'PRODID:-//PaintOps//Calendar Feed//EN';
    $lines[] = 'CALSCALE:GREGORIAN';
    $lines[] = 'METHOD:PUBLISH';
    $lines[] = paintops_ics_fold('X-WR-CALNAME:' . paintops_ics_escape(get_bloginfo('name') . ' - PaintOps'));
    // Hints only -- not every calendar app honors these, but Apple/Google
    // both look at one or the other to decide how often to re-poll.
    $lines[] = 'REFRESH-INTERVAL;VALUE=DURATION:PT12H';
    $lines[] = 'X-PUBLISHED-TTL:PT12H';

    foreach ($jobs as $job) {
        $lines[] = paintops_ics_event_for_job($job, $site_host);
    }

    $lines[] = 'END:VCALENDAR';

    return implode("\r\n", $lines) . "\r\n";
}

/**
 * Front controller for the feed URL. Hooked on init, the same pattern
 * as the public customer-quote page in customer-payments.php: no WP
 * login, gated by hash_equals() against an unguessable token, and it
 * exits immediately so nothing else (theme, other plugins) touches
 * the response.
 */
function paintops_handle_calendar_feed_request(): void {
    if (!isset($_GET['paintops_calendar_feed'])) {
        return;
    }

    // No nonce is possible here BY DESIGN: calendar apps poll this feed
    // URL with no WP session. Access is authenticated by the hash_equals()
    // check directly below against the per-install secret token; the value
    // read here feeds only that check.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash((string) $_GET['token'])) : '';
    $expected = paintops_calendar_feed_token();

    if ($token === '' || !hash_equals($expected, $token)) {
        status_header(403);
        header('Content-Type: text/plain; charset=utf-8');
        echo 'Invalid or expired calendar link.';
        exit;
    }

    header('Content-Type: text/calendar; charset=utf-8');
    header('Content-Disposition: inline; filename="paintops.ics"');
    // This response is text/calendar (RFC 5545), not HTML: every dynamic
    // value in the feed is escaped for the ICS context with
    // paintops_ics_escape() (backslash/semicolon/comma/newline) at the
    // moment it is placed on its content line, and job descriptions are
    // additionally passed through wp_strip_all_tags(). The esc_html()
    // family targets HTML output and would corrupt this format (RFC 5545
    // section 3.3.11 requires the backslash escapes used here).
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ICS output, escaped per RFC 5545 at assembly; HTML escaping does not apply to text/calendar
    echo paintops_build_calendar_ics();
    exit;
}
add_action('init', 'paintops_handle_calendar_feed_request');
