<?php

/**
 * SchedulerService - Main cron job orchestrator
 *
 * Handles:
 * - Lock acquisition/release (prevent concurrent runs)
 * - Creating jobs for due dates/recurrences
 * - Processing pending jobs (claim, send, mark)
 * - Creating history records
 * - Creating subsequent jobs
 * - Updating recurrence next_trigger_at
 * - Rate limiting Telegram sends
 * - Resetting stuck jobs
 */

declare(strict_types=1);

namespace Notifier\Services;

use PDO;
use DateTime;
use DateTimeZone;
use Exception;

class SchedulerService
{
    private PDO $pdo;
    private JobService $jobService;
    private TelegramService $telegram;
    private RecurrenceCalculator $recurrenceCalc;
    private string $timezone;
    private array $config;
    private array $stats;

    // Lock file handle
    private $lockHandle = null;

    public function __construct(
        PDO $pdo,
        JobService $jobService,
        TelegramService $telegram,
        RecurrenceCalculator $recurrenceCalc,
        string $timezone,
        array $config = []
    ) {
        $this->pdo = $pdo;
        $this->jobService = $jobService;
        $this->telegram = $telegram;
        $this->recurrenceCalc = $recurrenceCalc;
        $this->timezone = $timezone;
        $this->config = array_merge([
            'lock_file' => '/tmp/notifier.lock',
            'lock_timeout' => 300,
            'batch_size' => 50,
            'rate_limit_ms' => 100,
        ], $config);
        $this->resetStats();
    }

    /**
     * Reset run statistics.
     */
    private function resetStats(): void
    {
        $this->stats = [
            'started_at' => null,
            'finished_at' => null,
            'jobs_created' => 0,
            'jobs_processed' => 0,
            'jobs_sent' => 0,
            'jobs_failed' => 0,
            'stuck_reset' => 0,
            'recurrences_updated' => 0,
            'errors' => [],
        ];
    }

    /**
     * Get statistics from the last run.
     */
    public function getStats(): array
    {
        return $this->stats;
    }

    /**
     * Main entry point - run the scheduler.
     *
     * @return bool True if run completed successfully
     */
    public function run(): bool
    {
        $this->resetStats();
        $this->stats['started_at'] = date('Y-m-d H:i:s');

        // Acquire lock
        if (!$this->acquireLock()) {
            $this->stats['errors'][] = 'Failed to acquire lock';
            return false;
        }

        try {
            // Step 1: Reset stuck jobs
            $this->stats['stuck_reset'] = $this->jobService->resetStuckJobs();

            // Step 2: Create jobs for due dates and recurrences
            $dateJobs = $this->jobService->createJobsForDueDates();
            $recurrenceJobs = $this->jobService->createJobsForDueRecurrences();
            $this->stats['jobs_created'] = $dateJobs + $recurrenceJobs;

            // Step 3: Process pending jobs
            $this->processJobs();

            // Step 4: Update recurrence next_trigger_at for sent jobs
            $this->updateRecurrences();

            $this->stats['finished_at'] = date('Y-m-d H:i:s');
            return true;
        } catch (Exception $e) {
            $this->stats['errors'][] = $e->getMessage();
            return false;
        } finally {
            $this->releaseLock();
        }
    }

    /**
     * Acquire exclusive lock.
     *
     * @return bool True if lock acquired
     */
    public function acquireLock(): bool
    {
        $lockFile = $this->config['lock_file'];

        // Check for stale lock
        if (file_exists($lockFile)) {
            $lockAge = time() - filemtime($lockFile);
            if ($lockAge > $this->config['lock_timeout']) {
                // Stale lock - check if process is still running
                $pid = (int) @file_get_contents($lockFile);
                if ($pid > 0 && !$this->isProcessRunning($pid)) {
                    // Process is dead, remove stale lock
                    @unlink($lockFile);
                } elseif ($lockAge > $this->config['lock_timeout'] * 2) {
                    // Very stale, force remove
                    @unlink($lockFile);
                } else {
                    return false;
                }
            } else {
                return false;
            }
        }

        // Create lock file
        $this->lockHandle = @fopen($lockFile, 'x');
        if ($this->lockHandle === false) {
            // Another process may have created it
            return false;
        }

        // Write PID
        fwrite($this->lockHandle, (string) getmypid());
        fflush($this->lockHandle);

        return true;
    }

    /**
     * Release the lock.
     */
    public function releaseLock(): void
    {
        if ($this->lockHandle !== null) {
            fclose($this->lockHandle);
            $this->lockHandle = null;
        }
        @unlink($this->config['lock_file']);
    }

    /**
     * Check if a process is running.
     */
    private function isProcessRunning(int $pid): bool
    {
        if ($pid <= 0) {
            return false;
        }
        // posix_kill with signal 0 checks if process exists
        if (function_exists('posix_kill')) {
            return posix_kill($pid, 0);
        }
        // Fallback: check /proc on Linux
        return file_exists("/proc/{$pid}");
    }

    /**
     * Check if lock is currently held.
     */
    public function isLocked(): bool
    {
        return $this->lockHandle !== null;
    }

    /**
     * Process pending jobs.
     */
    private function processJobs(): void
    {
        $batchSize = $this->config['batch_size'];
        $rateLimitMs = $this->config['rate_limit_ms'];

        // Get due jobs
        $jobs = $this->jobService->getDueJobs($batchSize);

        foreach ($jobs as $job) {
            $this->stats['jobs_processed']++;

            // Claim the job
            if (!$this->jobService->claim((int) $job['id'])) {
                // Someone else claimed it
                continue;
            }

            // Create history record first (needed for confirmation link)
            // We'll update it with message_id after send, or delete on failure
            $historyId = $this->createHistoryPlaceholder($job);

            // Send the message
            $result = $this->sendJob($job, $historyId);

            if ($result['success']) {
                $this->stats['jobs_sent']++;

                // Update history with message ID
                $this->updateHistoryMessageId($historyId, $result['message_id']);

                // Mark as sent
                $this->jobService->markSent(
                    (int) $job['id'],
                    $result['message_id'],
                    $historyId
                );

                // Create subsequent jobs if this is a main job
                if ($job['job_type'] !== 'subsequent') {
                    $this->jobService->createSubsequentJobs(
                        (int) $job['notification_id'],
                        $historyId,
                        $job['scheduled_for']
                    );
                }

                // Handle yearly repeat for date-based jobs
                if ($job['job_type'] === 'main_date') {
                    $this->handleYearlyRepeat($job);
                }
            } else {
                $this->stats['jobs_failed']++;

                // Delete the placeholder history record
                $this->deleteHistory($historyId);

                // Get current attempt count
                $currentJob = $this->jobService->getById((int) $job['id']);
                $attemptCount = (int) ($currentJob['attempt_count'] ?? 1);

                // Mark as failed with retry
                $this->jobService->markFailed(
                    (int) $job['id'],
                    $result['error'] ?? 'Unknown error',
                    $attemptCount
                );
            }

            // Rate limiting - delay between sends
            if ($rateLimitMs > 0) {
                usleep($rateLimitMs * 1000);
            }
        }
    }

    /**
     * Send a job via Telegram.
     *
     * @param array $job Job data with notification info
     * @param int $historyId History record ID for the confirmation link
     * @return array ['success' => bool, 'message_id' => int|null, 'error' => string|null]
     */
    private function sendJob(array $job, int $historyId): array
    {
        // Get the message content (no title - title is UI-only per spec)
        if ($job['job_type'] === 'subsequent') {
            $resolved = $this->resolveSubsequentContent($job);
            if (!$resolved['success']) {
                return [
                    'success' => false,
                    'message_id' => null,
                    'error' => $resolved['error'],
                ];
            }
            $message = $resolved['message'];
        } else {
            $message = $job['message'] ?? '';
        }

        // Format message preserving user's intentional markdown (*bold*, _italic_, ~strike~)
        $text = TelegramService::formatMarkdownV2($message);

        $keyboard = null;
        $confirmToken = $this->resolveConfirmToken($job, $historyId);
        if ($confirmToken !== null) {
            $confirmUrl = $this->buildConfirmUrl($confirmToken);
            if ($confirmUrl !== null) {
                $keyboard = TelegramService::createConfirmLinkButton($confirmUrl);
            }
        }

        $imagePath = $job['job_type'] === 'subsequent'
            ? ($resolved['image_path'] ?? null)
            : ($job['image_path'] ?? null);
        $resolvedImagePath = $this->resolveUploadPath($imagePath);

        try {
            // Check if there's an image
            if ($resolvedImagePath && file_exists($resolvedImagePath)) {
                // sendPhoto(path, caption, parseMode, keyboard, chatId)
                $result = $this->telegram->sendPhoto($resolvedImagePath, $text, 'MarkdownV2', $keyboard);
            } else {
                // sendMessage(text, parseMode, keyboard, chatId)
                $result = $this->telegram->sendMessage($text, 'MarkdownV2', $keyboard);
            }

            if ($result['success'] ?? false) {
                return [
                    'success' => true,
                    'message_id' => $result['message_id'] ?? 0,
                    'error' => null,
                ];
            } else {
                return [
                    'success' => false,
                    'message_id' => null,
                    'error' => $result['error'] ?? 'Unknown Telegram error',
                ];
            }
        } catch (Exception $e) {
            return [
                'success' => false,
                'message_id' => null,
                'error' => $e->getMessage(),
            ];
        }
    }

    /**
     * Resolve the confirmation token for a job.
     */
    private function resolveConfirmToken(array $job, int $historyId): ?string
    {
        $tokenHistoryId = $job['job_type'] === 'subsequent'
            ? ($job['source_history_id'] ?? null)
            : $historyId;

        if (!$tokenHistoryId) {
            return null;
        }

        $stmt = $this->pdo->prepare('
            SELECT confirm_token
            FROM notification_history
            WHERE id = ?
        ');
        $stmt->execute([$tokenHistoryId]);
        $token = $stmt->fetchColumn();

        if (!\is_string($token)) {
            return null;
        }

        $token = \trim($token);
        return $token === '' ? null : $token;
    }

    /**
     * Build the confirmation URL for the button.
     */
    private function buildConfirmUrl(string $token): ?string
    {
        $base = $this->config['app_url'] ?? null;
        if (!\is_string($base)) {
            return null;
        }
        $base = rtrim($base, '/');
        if ($base === '') {
            return null;
        }

        return $base . '/confirm/' . rawurlencode($token) . '/';
    }

    /**
     * Resolve follow-up message and image data for a subsequent job.
     *
     * @return array ['success' => bool, 'message' => string|null, 'image_path' => string|null, 'error' => string|null]
     */
    private function resolveSubsequentContent(array $job): array
    {
        $message = isset($job['subsequent_message']) ? \trim((string) $job['subsequent_message']) : '';
        $imagePath = $job['subsequent_image_path'] ?? null;
        if (\is_string($imagePath)) {
            $imagePath = \trim($imagePath);
            if ($imagePath === '') {
                $imagePath = null;
            }
        }

        if ($message === '' || $message === null) {
            $sourceId = $job['source_id'] ?? null;
            if ($sourceId) {
                $stmt = $this->pdo->prepare('
                    SELECT message, image_path
                    FROM notification_subsequents
                    WHERE id = ?
                ');
                $stmt->execute([$sourceId]);
                $row = $stmt->fetch(PDO::FETCH_ASSOC);
                if ($row) {
                    $message = \trim((string) ($row['message'] ?? ''));
                    $rowImage = $row['image_path'] ?? null;
                    if (\is_string($rowImage)) {
                        $rowImage = \trim($rowImage);
                        $rowImage = $rowImage === '' ? null : $rowImage;
                    }
                    $imagePath = $imagePath ?? $rowImage;
                }
            }
        }

        if ($message === '' || $message === null) {
            return [
                'success' => false,
                'message' => null,
                'image_path' => null,
                'error' => 'Subsequent message missing',
            ];
        }

        return [
            'success' => true,
            'message' => $message,
            'image_path' => $imagePath,
            'error' => null,
        ];
    }

    /**
     * Resolve a relative upload path against the configured uploads directory.
     */
    private function resolveUploadPath(?string $relativePath): ?string
    {
        if (!$relativePath) {
            return null;
        }

        $baseDir = $this->config['upload_path'] ?? (__DIR__ . '/../../uploads');
        $baseReal = realpath($baseDir);
        if ($baseReal === false) {
            return null;
        }

        $relativePath = ltrim($relativePath, '/');
        if (strpos($relativePath, '..') !== false) {
            return null;
        }

        return $baseReal . '/' . $relativePath;
    }

    /**
     * Create a placeholder history record (before sending).
     *
     * @param array $job Job data
     * @return int History record ID
     */
    private function createHistoryPlaceholder(array $job): int
    {
        $isSubsequent = $job['job_type'] === 'subsequent' ? 1 : 0;
        $subsequentIndex = null;
        $confirmToken = null;

        if ($isSubsequent && isset($job['source_id'])) {
            // Get the sort_order from notification_subsequents
            $stmt = $this->pdo->prepare('
                SELECT sort_order FROM notification_subsequents WHERE id = ?
            ');
            $stmt->execute([$job['source_id']]);
            $subsequentIndex = $stmt->fetchColumn();
        }
        if (!$isSubsequent) {
            $confirmToken = \bin2hex(\random_bytes(16));
        }

        $stmt = $this->pdo->prepare('
            INSERT INTO notification_history
            (notification_id, send_job_id, telegram_message_id, sent_at, is_subsequent, subsequent_index, confirm_token)
            VALUES (?, ?, NULL, datetime("now"), ?, ?, ?)
        ');
        $stmt->execute([
            $job['notification_id'],
            $job['id'],
            $isSubsequent,
            $subsequentIndex,
            $confirmToken,
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Update history record with Telegram message ID.
     *
     * @param int $historyId History record ID
     * @param int $messageId Telegram message ID
     */
    private function updateHistoryMessageId(int $historyId, int $messageId): void
    {
        $stmt = $this->pdo->prepare('
            UPDATE notification_history SET telegram_message_id = ? WHERE id = ?
        ');
        $stmt->execute([$messageId, $historyId]);
    }

    /**
     * Delete a history record (on send failure).
     *
     * @param int $historyId History record ID
     */
    private function deleteHistory(int $historyId): void
    {
        $stmt = $this->pdo->prepare('DELETE FROM notification_history WHERE id = ?');
        $stmt->execute([$historyId]);
    }

    /**
     * Update recurrence next_trigger_at for notifications that were just sent.
     */
    private function updateRecurrences(): void
    {
        // Find recurrence notifications that have sent jobs and need next_trigger_at update
        $stmt = $this->pdo->query("
            SELECT n.id, n.timezone, MAX(j.scheduled_for) AS last_scheduled_for
            FROM notifications n
            JOIN notification_recurrence r ON n.id = r.notification_id
            JOIN send_jobs j ON n.id = j.notification_id
            WHERE n.schedule_type = 'recurrence'
            AND n.status = 'active'
            AND j.job_type = 'main_recurrence'
            AND j.status = 'sent'
            AND j.sent_at >= datetime('now', '-5 minutes')
            GROUP BY n.id, n.timezone
        ");
        $notifications = $stmt->fetchAll(PDO::FETCH_ASSOC);

        foreach ($notifications as $notif) {
            $timezone = $notif['timezone'] ?: $this->timezone;
            $afterUtc = $notif['last_scheduled_for'] ?? null;
            $this->updateNextTrigger((int) $notif['id'], $timezone, $afterUtc);
            $this->stats['recurrences_updated']++;
        }
    }

    /**
     * Update next_trigger_at for a recurrence notification.
     *
     * @param int $notificationId Notification ID
     * @param string $timezone Notification timezone
     * @param string|null $afterUtc UTC datetime string to calculate after
     */
    private function updateNextTrigger(int $notificationId, string $timezone, ?string $afterUtc): void
    {
        // Get recurrence config
        $stmt = $this->pdo->prepare('
            SELECT * FROM notification_recurrence WHERE notification_id = ?
        ');
        $stmt->execute([$notificationId]);
        $recurrence = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$recurrence) {
            return;
        }

        // Build recurrence array for calculator
        $recurrenceConfig = [
            'type' => $recurrence['type'],
            'time_of_day' => $recurrence['time_of_day'],
        ];

        // Add type-specific fields
        switch ($recurrence['type']) {
            case 'specific_months':
                $recurrenceConfig['months'] = json_decode($recurrence['months'], true);
                $recurrenceConfig['day_of_month'] = (int) $recurrence['day_of_month'];
                if (!empty($recurrence['weekend_adjust'])) {
                    $recurrenceConfig['weekend_adjust'] = $recurrence['weekend_adjust'];
                }
                break;
            case 'monthly_date':
                $recurrenceConfig['day_of_month'] = (int) $recurrence['day_of_month'];
                if (!empty($recurrence['weekend_adjust'])) {
                    $recurrenceConfig['weekend_adjust'] = $recurrence['weekend_adjust'];
                }
                break;
            case 'monthly_weekday':
                $recurrenceConfig['week_number'] = $recurrence['week_number'];
                $recurrenceConfig['weekday'] = (int) $recurrence['weekday'];
                break;
            case 'weekly':
                $recurrenceConfig['weekdays'] = json_decode($recurrence['weekdays'], true);
                break;
            case 'daily':
                $recurrenceConfig['interval_days'] = (int) $recurrence['interval_days'];
                $recurrenceConfig['anchor_date'] = $recurrence['anchor_date'];
                break;
        }

        $nextTrigger = $this->recurrenceCalc->calculateNext(
            $recurrenceConfig,
            $timezone,
            $afterUtc
        );

        if ($nextTrigger) {
            $stmt = $this->pdo->prepare('
                UPDATE notification_recurrence
                SET next_trigger_at = ?
                WHERE notification_id = ?
            ');
            $stmt->execute([$nextTrigger, $notificationId]);
        }
    }

    /**
     * Run without lock (for testing).
     */
    public function runWithoutLock(): bool
    {
        $this->resetStats();
        $this->stats['started_at'] = date('Y-m-d H:i:s');

        try {
            $this->stats['stuck_reset'] = $this->jobService->resetStuckJobs();

            $dateJobs = $this->jobService->createJobsForDueDates();
            $recurrenceJobs = $this->jobService->createJobsForDueRecurrences();
            $this->stats['jobs_created'] = $dateJobs + $recurrenceJobs;

            $this->processJobs();
            $this->updateRecurrences();

            $this->stats['finished_at'] = date('Y-m-d H:i:s');
            return true;
        } catch (Exception $e) {
            $this->stats['errors'][] = $e->getMessage();
            return false;
        }
    }

    /**
     * Handle yearly repeat for date-based notifications.
     * After sending a date, create next year's date if yearly_repeat is enabled.
     *
     * @param array $job Job data with notification info
     */
    private function handleYearlyRepeat(array $job): void
    {
        // Check if notification has yearly_repeat enabled
        $stmt = $this->pdo->prepare('
            SELECT yearly_repeat FROM notifications WHERE id = ?
        ');
        $stmt->execute([$job['notification_id']]);
        $notification = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$notification || !$notification['yearly_repeat']) {
            return;
        }

        // Get the source date ID
        $sourceId = $job['source_id'] ?? null;
        if (!$sourceId) {
            return;
        }

        // Get the original date
        $stmt = $this->pdo->prepare('
            SELECT scheduled_at FROM notification_dates WHERE id = ?
        ');
        $stmt->execute([$sourceId]);
        $dateRow = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$dateRow) {
            return;
        }

        // Calculate next year's date
        $originalDate = new DateTime($dateRow['scheduled_at'], new DateTimeZone('UTC'));
        $nextYearDate = clone $originalDate;
        $nextYearDate->modify('+1 year');

        // Check if next year's date already exists
        $nextYearStr = $nextYearDate->format('Y-m-d H:i:s');
        $stmt = $this->pdo->prepare('
            SELECT id FROM notification_dates
            WHERE notification_id = ? AND scheduled_at = ?
        ');
        $stmt->execute([$job['notification_id'], $nextYearStr]);

        if ($stmt->fetch()) {
            // Already exists, skip
            return;
        }

        // Insert next year's date
        $stmt = $this->pdo->prepare('
            INSERT INTO notification_dates (notification_id, scheduled_at)
            VALUES (?, ?)
        ');
        $stmt->execute([$job['notification_id'], $nextYearStr]);
    }

    /**
     * Dry run - simulate without actually sending.
     *
     * @return array Jobs that would be processed
     */
    public function dryRun(): array
    {
        $result = [
            'stuck_jobs' => 0,
            'date_jobs_to_create' => 0,
            'recurrence_jobs_to_create' => 0,
            'pending_jobs' => [],
        ];

        // Count stuck jobs
        $stmt = $this->pdo->query("
            SELECT COUNT(*) FROM send_jobs
            WHERE status = 'sending'
            AND updated_at < datetime('now', '-10 minutes')
        ");
        $result['stuck_jobs'] = (int) $stmt->fetchColumn();

        // Count due dates without jobs
        $stmt = $this->pdo->query("
            SELECT COUNT(*) FROM notification_dates d
            JOIN notifications n ON d.notification_id = n.id
            WHERE d.scheduled_at <= datetime('now')
            AND n.status = 'active'
            AND n.schedule_type = 'dates'
            AND NOT EXISTS (
                SELECT 1 FROM send_jobs j
                WHERE j.notification_id = d.notification_id
                AND j.source_id = d.id
                AND j.job_type = 'main_date'
            )
        ");
        $result['date_jobs_to_create'] = (int) $stmt->fetchColumn();

        // Count due recurrences without jobs
        $stmt = $this->pdo->query("
            SELECT COUNT(*) FROM notification_recurrence r
            JOIN notifications n ON r.notification_id = n.id
            WHERE r.next_trigger_at IS NOT NULL
            AND r.next_trigger_at <= datetime('now')
            AND n.status = 'active'
            AND n.schedule_type = 'recurrence'
            AND NOT EXISTS (
                SELECT 1 FROM send_jobs j
                WHERE j.notification_id = r.notification_id
                AND j.job_type = 'main_recurrence'
                AND j.scheduled_for = r.next_trigger_at
            )
        ");
        $result['recurrence_jobs_to_create'] = (int) $stmt->fetchColumn();

        // Get pending jobs
        $jobs = $this->jobService->getDueJobs($this->config['batch_size']);
        foreach ($jobs as $job) {
            $result['pending_jobs'][] = [
                'id' => $job['id'],
                'notification_id' => $job['notification_id'],
                'title' => $job['title'],
                'job_type' => $job['job_type'],
                'scheduled_for' => $job['scheduled_for'],
                'status' => $job['status'],
            ];
        }

        return $result;
    }
}
