<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * Durable retry queue for platform email.
 *
 * When {@see Email::send()} fails on EVERY configured server (primary + fallbacks),
 * the message is persisted here and retried later with exponential backoff — so a
 * transient outage that takes out all providers at once does not silently lose the
 * mail. This is the durability layer on top of the multi-server failover.
 *
 * File-based (storage/mail-queue/) so it needs no database schema and works on any
 * host: one JSON file per pending message; a message that exhausts its attempts is
 * moved to storage/mail-queue/failed/ (a dead-letter box the owner can inspect and
 * re-queue). Processing is serialised with a non-blocking flock so two runners — a
 * request tick and a cron run — never double-send.
 */
final class MailQueue
{
    /** Give up (dead-letter) after this many failed attempts. */
    private const MAX_ATTEMPTS = 6;

    /** Backoff before each retry, in seconds, indexed by attempt number (1-based). */
    private const BACKOFF = [120, 600, 1800, 7200, 21600, 43200];   // 2m, 10m, 30m, 2h, 6h, 12h

    /** Request-driven tick runs at most this often, and processes at most this many. */
    private const TICK_INTERVAL = 120;
    private const TICK_BATCH     = 5;

    private static function dir(): string
    {
        return (defined('BASE_PATH') ? BASE_PATH : dirname(__DIR__)) . '/storage/mail-queue';
    }

    private static function ensure(string $dir): bool
    {
        return is_dir($dir) || @mkdir($dir, 0775, true);
    }

    /**
     * Persist a message for later delivery.
     *
     * @param array $params  Exactly what Email::send() accepts (to, subject, body/htmlbody, cc…).
     * @param array $meta     Optional: ['template' => key, 'error' => last error].
     * @return string         The queue id, or '' if it could not be written.
     */
    public static function enqueue(array $params, array $meta = []): string
    {
        $dir = self::dir();
        if (!self::ensure($dir)) return '';
        if (empty($params['to'])) return '';

        $id  = date('Ymd-His') . '-' . bin2hex(random_bytes(4));
        $rec = [
            'id'              => $id,
            'params'          => $params,
            'attempts'        => 0,
            'max_attempts'    => self::MAX_ATTEMPTS,
            'next_attempt_at' => time(),          // eligible on the next tick
            'created_at'      => time(),
            'updated_at'      => time(),
            'last_error'      => (string) ($meta['error'] ?? ''),
            'template'        => (string) ($meta['template'] ?? ''),
        ];
        $ok = @file_put_contents($dir . '/' . $id . '.json', self::encode($rec), LOCK_EX);
        return $ok !== false ? $id : '';
    }

    /**
     * Process due messages. Serialised via flock; a second concurrent caller returns
     * immediately with zeroes.
     *
     * @return array{sent:int,retried:int,failed:int,processed:int,pending:int}
     */
    public static function process(int $limit = 50): array
    {
        $dir   = self::dir();
        $stats = ['sent' => 0, 'retried' => 0, 'failed' => 0, 'processed' => 0, 'pending' => 0];
        if (!is_dir($dir)) return $stats;

        $lock = @fopen($dir . '/.lock', 'c');
        if (!$lock || !@flock($lock, LOCK_EX | LOCK_NB)) {
            if ($lock) fclose($lock);
            return $stats;                       // another runner holds the lock
        }

        $files = glob($dir . '/*.json') ?: [];
        sort($files);                            // oldest first (ids are timestamped)
        $now = time();
        foreach ($files as $file) {
            if ($stats['processed'] >= $limit) break;
            $rec = json_decode((string) @file_get_contents($file), true);
            if (!is_array($rec) || empty($rec['params']['to'])) { @unlink($file); continue; }  // corrupt → drop
            if ((int) ($rec['next_attempt_at'] ?? 0) > $now) continue;                          // not due yet

            $stats['processed']++;
            PlatformSettings::primeMailer();
            $res = Email::send((array) $rec['params']);

            if (!empty($res['mailed'])) {
                @unlink($file);
                $stats['sent']++;
                continue;
            }

            $rec['attempts']   = (int) ($rec['attempts'] ?? 0) + 1;
            $rec['last_error'] = (string) ($res['message'] ?? 'unknown error');
            $rec['updated_at'] = $now;

            if ($rec['attempts'] >= (int) ($rec['max_attempts'] ?? self::MAX_ATTEMPTS)) {
                $failedDir = $dir . '/failed';
                if (self::ensure($failedDir)) {
                    @file_put_contents($failedDir . '/' . basename($file), self::encode($rec));
                }
                @unlink($file);
                $stats['failed']++;
            } else {
                $wait = self::BACKOFF[min($rec['attempts'] - 1, count(self::BACKOFF) - 1)];
                $rec['next_attempt_at'] = $now + $wait;
                @file_put_contents($file, self::encode($rec), LOCK_EX);
                $stats['retried']++;
            }
        }

        @flock($lock, LOCK_UN);
        fclose($lock);
        $stats['pending'] = self::pending();
        return $stats;
    }

    /**
     * Light, rate-limited tick, safe to call on every web request. Does nothing when
     * the queue is empty or the interval has not elapsed, so it costs a stat() call.
     * Heavier backlogs should also be drained by the cron CLI (bin/mail-queue.php).
     */
    public static function tickIfDue(): void
    {
        $dir = self::dir();
        if (!is_dir($dir)) return;
        $marker = $dir . '/.last-tick';
        $last   = is_file($marker) ? (int) @filemtime($marker) : 0;
        if (time() - $last < self::TICK_INTERVAL) return;
        @touch($marker);
        if (self::pending() === 0) return;
        self::process(self::TICK_BATCH);
    }

    /** Count of messages waiting to be (re)tried. */
    public static function pending(): int
    {
        $dir = self::dir();
        return is_dir($dir) ? count(glob($dir . '/*.json') ?: []) : 0;
    }

    /** Count of dead-lettered messages (exhausted all attempts). */
    public static function failedCount(): int
    {
        $dir = self::dir() . '/failed';
        return is_dir($dir) ? count(glob($dir . '/*.json') ?: []) : 0;
    }

    /**
     * Move every dead-lettered message back into the live queue (attempts reset,
     * due immediately). Returns how many were re-queued.
     */
    public static function retryFailed(): int
    {
        $failedDir = self::dir() . '/failed';
        if (!is_dir($failedDir)) return 0;
        $n = 0;
        foreach (glob($failedDir . '/*.json') ?: [] as $file) {
            $rec = json_decode((string) @file_get_contents($file), true);
            if (!is_array($rec) || empty($rec['params']['to'])) { @unlink($file); continue; }
            $rec['attempts']        = 0;
            $rec['next_attempt_at'] = time();
            $rec['updated_at']      = time();
            if (@file_put_contents(self::dir() . '/' . basename($file), self::encode($rec)) !== false) {
                @unlink($file);
                $n++;
            }
        }
        return $n;
    }

    private static function encode(array $rec): string
    {
        return (string) json_encode($rec, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
}
