<?php

declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Mailer;

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

/**
 * Cron Service
 * Handles all scheduled tasks
 */
class CronService
{
    /**
     * Register cron hooks
     */
    public static function init(): void
    {
        // Schedule events on plugin activation
        add_action('instinctor_hourly_cron', [self::class, 'runHourlyTasks']);
        add_action('instinctor_daily_cron', [self::class, 'runDailyTasks']);
        add_action('instinctor_weekly_cron', [self::class, 'runWeeklyTasks']);
    }

    /**
     * Schedule cron events (call on activation)
     */
    public static function schedule(): void
    {
        if (!wp_next_scheduled('instinctor_hourly_cron')) {
            wp_schedule_event(time(), 'hourly', 'instinctor_hourly_cron');
        }

        if (!wp_next_scheduled('instinctor_daily_cron')) {
            wp_schedule_event(time(), 'daily', 'instinctor_daily_cron');
        }

        if (!wp_next_scheduled('instinctor_weekly_cron')) {
            wp_schedule_event(time(), 'weekly', 'instinctor_weekly_cron');
        }
    }

    /**
     * Unschedule cron events (call on deactivation)
     */
    public static function unschedule(): void
    {
        wp_clear_scheduled_hook('instinctor_hourly_cron');
        wp_clear_scheduled_hook('instinctor_daily_cron');
        wp_clear_scheduled_hook('instinctor_weekly_cron');
    }

    /**
     * Hourly tasks
     */
    public static function runHourlyTasks(): void
    {
        self::processScheduledGiftCards();
        self::updateOrderTracking();
        self::sendBookingReminders();
    }

    /**
     * Daily tasks
     */
    public static function runDailyTasks(): void
    {
        self::expireLoyaltyPoints();
        self::expireGiftCards();
        self::generateClassInstances();
        self::rollBookingSeries();
        self::sendLowStockAlerts();
        self::updateCurrencyRates();
        \Instinctor\Core\Queue::prune(7);              // Remove completed/failed jobs older than 7 days
        \Instinctor\Core\Security::pruneLoginAttempts(30); // Remove old brute-force records
    }

    /**
     * Weekly tasks
     */
    public static function runWeeklyTasks(): void
    {
        self::sendLoyaltyReminders();
        self::cleanupOldData();
    }

    /**
     * Send scheduled gift cards
     */
    private static function processScheduledGiftCards(): void
    {
        $table = DB::prefix() . 'instinctor_gift_cards';

        $cards = DB::get_results(
            "SELECT * FROM $table 
             WHERE status = 'pending' 
             AND deliver_at IS NOT NULL 
             AND deliver_at <= NOW() 
             AND recipient_email != ''"
        );

        foreach ($cards as $card) {
            if (GiftCardService::sendEmail($card->id)) {
                DB::update($table, ['status' => 'active'], ['id' => $card->id]);
            }
        }
    }

    /**
     * Update order tracking from carriers
     */
    private static function updateOrderTracking(): void
    {
        // Use AfterShip if enabled
        if (AfterShipService::isEnabled()) {
            AfterShipService::updateAllTrackings();
            return;
        }
        
        // Fallback to manual carrier API calls
        $table = DB::prefix() . 'instinctor_order_tracking';

        // Get tracking that needs updates (not delivered, updated more than 2 hours ago)
        $tracking = DB::get_results(
            "SELECT * FROM $table 
             WHERE status NOT IN ('delivered', 'failed') 
             AND updated_at < DATE_SUB(NOW(), INTERVAL 2 HOUR)
             LIMIT 50"
        );

        foreach ($tracking as $t) {
            $status = self::fetchTrackingStatus($t->carrier, $t->tracking_number);

            if ($status && $status !== $t->status) {
                OrderTrackingService::updateStatus($t->id, $status['status'], $status['detail'] ?? null);
            }
        }
    }

    /**
     * Fetch tracking status from carrier API. Only USPS is implemented; other
     * carriers return null and the tracking row stays unchanged until AfterShip
     * (the preferred integration) or a manual admin update advances it.
     */
    private static function fetchTrackingStatus(string $carrier, string $trackingNumber): ?array
    {
        if ($carrier === 'usps') {
            return self::fetchUSPSStatus($trackingNumber);
        }
        return null;
    }

    /**
     * Fetch USPS tracking status
     */
    private static function fetchUSPSStatus(string $trackingNumber): ?array
    {
        $userId = Config::get('instinctor_usps_user_id', '');
        if (!$userId) {
            return null;
        }

        $xml = '<?xml version="1.0"?>
            <TrackRequest USERID="' . esc_attr($userId) . '">
                <TrackID ID="' . esc_attr($trackingNumber) . '"></TrackID>
            </TrackRequest>';

        $response = wp_remote_get(
            'https://secure.shippingapis.com/ShippingAPI.dll?API=TrackV2&XML=' . urlencode($xml),
            ['timeout' => 15]
        );

        if (is_wp_error($response)) {
            return null;
        }

        $body = wp_remote_retrieve_body($response);
        $xml = @simplexml_load_string($body);

        if (!$xml || !isset($xml->TrackInfo->TrackSummary)) {
            return null;
        }

        $summary = (string) $xml->TrackInfo->TrackSummary;
        
        if (stripos($summary, 'delivered') !== false) {
            return ['status' => 'delivered', 'detail' => $summary];
        } elseif (stripos($summary, 'out for delivery') !== false) {
            return ['status' => 'out_for_delivery', 'detail' => $summary];
        } elseif (stripos($summary, 'in transit') !== false || stripos($summary, 'processed') !== false) {
            return ['status' => 'in_transit', 'detail' => $summary];
        }

        return null;
    }

    /**
     * Expire loyalty points
     */
    private static function expireLoyaltyPoints(): void
    {
        $p = DB::prefix();
        $settings = LoyaltyService::getSettings();
        
        if (!$settings['enabled'] || empty($settings['points_expire_days'])) {
            return;
        }

        $expireDays = intval($settings['points_expire_days']);
        
        // Find points that should expire
        $expiringPoints = DB::get_results(DB::prepare(
            "SELECT lp.*, c.email, c.first_name 
             FROM {$p}instinctor_loyalty_points lp
             JOIN {$p}instinctor_customers c ON lp.customer_id = c.id
             WHERE lp.points > 0 
             AND lp.updated_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
            $expireDays
        ));

        foreach ($expiringPoints as $record) {
            $pointsToExpire = $record->points;
            
            // Zero out points
            DB::update(
                DB::prefix() . 'instinctor_loyalty_points',
                ['points' => 0],
                ['id' => $record->id]
            );

            // Log expiration
            DB::insert(DB::prefix() . 'instinctor_loyalty_transactions', [
                'customer_id' => $record->customer_id,
                'points' => -$pointsToExpire,
                'type' => 'expire',
                'description' => 'Points expired after ' . $expireDays . ' days of inactivity',
            ]);

            // Notify customer
            if ($record->email) {
                EmailService::send('loyalty_expired', $record->email, [
                    'customer_name' => $record->first_name,
                    'points_expired' => $pointsToExpire,
                ]);
            }
        }
    }

    /**
     * Expire gift cards
     */
    private static function expireGiftCards(): void
    {
        $table = DB::prefix() . 'instinctor_gift_cards';

        DB::query(
            "UPDATE $table 
             SET status = 'expired' 
             WHERE status = 'active' 
             AND expires_at IS NOT NULL 
             AND expires_at < NOW()"
        );
    }

    /**
     * Send low stock alerts
     */
    private static function sendLowStockAlerts(): void
    {
        $settings = InventoryService::getSettings();
        
        if (!$settings['notify_low_stock']) {
            return;
        }

        $lowStock = InventoryService::getLowStockProducts();
        
        if (empty($lowStock)) {
            return;
        }

        // Check if we already sent alert today
        $lastAlert = Config::get('instinctor_last_low_stock_alert', '');
        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
        $today = date('Y-m-d');
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        
        if ($lastAlert === $today) {
            return;
        }

        $adminEmail = Config::get('admin_email');
        
        $productList = '';
        foreach ($lowStock as $product) {
            $productList .= "- {$product->name} (SKU: {$product->sku}) - {$product->stock_quantity} left\n";
        }

        Mailer::send(
            $adminEmail, '[' . get_bloginfo('name') . '] Low Stock Alert', "The following products are running low on stock:\n\n" . $productList . "\n\nManage inventory: " . admin_url('admin.php?page=instinctor-inventory'),
            ['Content-Type: text/plain; charset=UTF-8']
        );

        Config::set('instinctor_last_low_stock_alert', $today);
    }

    /**
     * Update currency exchange rates
     */
    private static function updateCurrencyRates(): void
    {
        CurrencyService::updateExchangeRates();
    }

    /**
     * Send loyalty reminders for inactive customers
     */
    private static function sendLoyaltyReminders(): void
    {
        $p = DB::prefix();
        $settings = LoyaltyService::getSettings();
        
        if (!$settings['enabled']) {
            return;
        }


        // Find customers with points who haven't ordered in 30+ days
        $customers = DB::get_results(
            "SELECT lp.*, c.email, c.first_name, c.last_name,
                    (SELECT MAX(created_at) FROM {$p}instinctor_orders WHERE customer_id = c.id) as last_order
             FROM {$p}instinctor_loyalty_points lp
             JOIN {$p}instinctor_customers c ON lp.customer_id = c.id
             WHERE lp.points >= 100
             HAVING last_order < DATE_SUB(NOW(), INTERVAL 30 DAY)
             LIMIT 50"
        );

        foreach ($customers as $customer) {
            // Check if we've already sent reminder this month
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
            $sentKey = 'instinctor_loyalty_reminder_' . $customer->customer_id . '_' . date('Y-m');
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
            if (get_transient($sentKey)) {
                continue;
            }

            $pointsValue = $customer->points * $settings['points_value'];

            EmailService::send('loyalty_reminder', $customer->email, [
                'customer_name' => $customer->first_name,
                'points' => $customer->points,
                'points_value' => CurrencyService::formatPrice($pointsValue),
                'tier' => ucfirst($customer->tier),
            ]);

            set_transient($sentKey, true, 30 * DAY_IN_SECONDS);
        }
    }

    /**
     * Cleanup old data
     */
    private static function cleanupOldData(): void
    {

        // Clean old search logs (stored in wp_options)
        $searches = Config::get('instinctor_search_log', []);
        if (!empty($searches)) {
            $cutoff = strtotime('-90 days');
            $searches = array_filter($searches, function ($entry) use ($cutoff) {
                return isset($entry['time']) && $entry['time'] > $cutoff;
            });
            Config::set('instinctor_search_log', array_values($searches));
        }
    }

    /**
     * Keep the studio class timetable populated on a rolling window so members
     * always have upcoming classes to book. Idempotent.
     */
    private static function generateClassInstances(): void
    {
        
    }

    /**
     * Roll every active recurring booking series forward.
     *
     * generateSeriesOccurrences() only materialises to a 90-day horizon, so
     * without this a fortnightly cleaning slot would simply stop appearing about
     * three months in, which reads as the schedule dying rather than as a
     * missing cron. The generator is idempotent (it resumes from
     * generated_through), so running daily costs nothing and cannot duplicate a
     * visit.
     *
     * Each series is rolled inside its OWN owner context: the shared host
     * carries every tenant in one table, and the generator checks slot
     * availability, which must be judged against that owner's calendar.
     *
     * Public because the daily hook only reaches it on WordPress: standalone
     * stubs out wp_schedule_event, so there the roll comes from bin/roll-booking-series.php
     * on system cron, exactly like dunning and purge-demos.
     *
     * @return array{series:int,created:int,skipped:int}
     */
    public static function rollBookingSeries(): array
    {
        $p = \Instinctor\Core\DB::prefix();
        $table = $p . 'instinctor_booking_series';

        // Absent on installs that have not migrated yet; skip rather than fatal.
        if (!\Instinctor\Core\DB::get_var("SHOW TABLES LIKE '" . $table . "'")) {
            return ['series' => 0, 'created' => 0, 'skipped' => 0];
        }

        $rows = \Instinctor\Core\DB::get_results(
            "SELECT id, owner_id FROM {$table}
              WHERE status = 'active'
                AND (generated_through IS NULL OR generated_through < DATE_ADD(CURDATE(), INTERVAL 60 DAY))"
        ) ?: [];

        $bs   = BookingService::instance();
        $made = 0;
        $skip = 0;
        foreach ($rows as $r) {
            try {
                $res = \Instinctor\Core\Tenant::withOwner(
                    $r->owner_id === null ? null : (int) $r->owner_id,
                    static function () use ($bs, $r) { return $bs->generateSeriesOccurrences((int) $r->id); }
                );
                $made += (int) ($res['created'] ?? 0);
                $skip += (int) ($res['skipped'] ?? 0);
            } catch (\Throwable $e) {
                // One bad series must never stop the rest of the roll.
                error_log('[Instinctor] series roll ' . $r->id . ': ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- server-side cron failure capture
            }
        }

        return ['series' => count($rows), 'created' => $made, 'skipped' => $skip];
    }

    /**
     * Send booking reminder emails
     */
    private static function sendBookingReminders(): void
    {
        $p = DB::prefix();
        
        // Get booking settings
        $bookingService = BookingService::instance();
        $settings = $bookingService->getSettings();
        
        if (empty($settings['send_reminders'])) {
            return;
        }
        
        $reminderHours = intval($settings['reminder_hours'] ?? 24);
        
        // Find confirmed bookings that are coming up and haven't been reminded
        $bookings = DB::get_results(DB::prepare(
            "SELECT b.*, p.name as service_name, c.email, c.first_name
             FROM {$p}instinctor_bookings b
             LEFT JOIN {$p}instinctor_products p ON b.product_id = p.id
             LEFT JOIN {$p}instinctor_customers c ON b.customer_id = c.id
             WHERE b.status = 'confirmed'
               AND b.start_time BETWEEN %s AND %s
               AND (b.meta IS NULL OR JSON_EXTRACT(b.meta, '$.reminder_sent') IS NULL)",
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
            date('Y-m-d H:i:s'),
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
            date('Y-m-d H:i:s', strtotime('+' . $reminderHours . ' hours'))
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
        ));
        
        if (empty($bookings)) {
            return;
        }
        
        $storeName = Config::get('instinctor_store_name', get_bloginfo('name'));
        
        foreach ($bookings as $booking) {
            // Automatic reminders are Pro. The Pro page sells them as the thing that
            // cuts no-shows, but the job sent them to every tier, so the one feature
            // an owner would most feel the absence of was being given away
            // (Sergei 2026-07-26). A free owner still sees the booking and can remind
            // by hand; Pro is what makes it happen without them.
            $rOwner = (int) ($booking->owner_id ?? 0);
            if ($rOwner > 0 && class_exists('\Instinctor\Core\TenantPlan')
                && !\Instinctor\Core\TenantPlan::isPro($rOwner)) {
                continue;
            }
            if (empty($booking->email)) {
                continue;
            }
            
            $startTime = strtotime($booking->start_time);
            $dateFormatted = date_i18n(Config::get('date_format', 'Y-m-d'), $startTime);
            $timeFormatted = date_i18n(Config::get('time_format', 'H:i'), $startTime);
            
            /* translators: placeholder values */
            $subject = sprintf(__('Reminder: Your appointment tomorrow - %s', 'instinctor'), $booking->service_name);
            /* translators: placeholder values */
            
            /* translators: %s: customer first name. */
            $message = sprintf(__('Hi %s,', 'instinctor'), $booking->first_name ?: 'there') . "\n\n";
            /* translators: placeholder values */
            $message .= __('This is a friendly reminder about your upcoming appointment:', 'instinctor') . "\n\n";
            /* translators: placeholder values */
            $message .= sprintf(__('Service: %s', 'instinctor'), $booking->service_name) . "\n";
            /* translators: placeholder values */
            $message .= sprintf(__('Date: %s', 'instinctor'), $dateFormatted) . "\n";
            /* translators: %s: appointment time. */
            /* translators: placeholder values */
            $message .= sprintf(__('Time: %s', 'instinctor'), $timeFormatted) . "\n\n";
            $message .= __('We look forward to seeing you!', 'instinctor') . "\n\n";
            $message .= $storeName;
            
            $headers = [
                'Content-Type: text/plain; charset=UTF-8',
                'From: ' . $storeName . ' <' . Config::get('admin_email') . '>',
            ];
            
            Mailer::send($booking->email, $subject, $message);
            
            // Mark as reminded
            $meta = json_decode($booking->meta ?? '{}', true);
            $meta['reminder_sent'] = current_time('mysql');
            DB::update(
                DB::prefix() . 'instinctor_bookings',
                ['meta' => json_encode($meta)],
                ['id' => $booking->id]
            );
        }
    }
}
