<?php

namespace App\Console\Commands\Utility;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\Console\Helper\TableSeparator;

class WarmTileCache extends \App\Console\Commands\BaseCommand
{
    protected $signature = 'tiles:warm
        {--min-zoom=3 : Lowest zoom level to warm}
        {--max-zoom=6 : Highest zoom level to warm}
        {--theme=* : Themes to warm (dark, light). Defaults to both}
        {--count : Print the tile counts and exit without fetching anything}
        {--force : Re-fetch and overwrite tiles already present in Redis}
        {--delay=500 : Base pause between fetches, in milliseconds}
        {--jitter=125 : Random +/- variation applied to the delay, in milliseconds}';

    protected $description = 'Fetch global low-zoom Carto basemap tiles and pin them in Redis permanently.';

    /** Theme name => Carto raster style slug. */
    private const STYLES = [
        'dark'  => 'dark_all',
        'light' => 'voyager',
    ];

    /** Refuse to run past this many tiles without an explicit code change. */
    private const MAX_TILES = 250_000;

    /** Rough per-tile size used only for the --count estimate. */
    private const ESTIMATED_BYTES = 6_500;

    private int $stored  = 0;
    private int $skipped = 0;
    private int $bytes   = 0;

    /** @var array<int, array{0: string, 1: int, 2: int, 3: int}> */
    private array $failed = [];

    public function handle(): int
    {
        $minZ = (int) $this->option('min-zoom');
        $maxZ = (int) $this->option('max-zoom');

        if ($minZ < 0 || $maxZ > 20 || $minZ > $maxZ) {
            $this->error('Zoom range must satisfy 0 <= min-zoom <= max-zoom <= 20.');

            return self::FAILURE;
        }

        $themes = $this->option('theme') ?: array_keys(self::STYLES);

        foreach ($themes as $theme) {
            if (! isset(self::STYLES[$theme])) {
                $this->error("Unknown theme [{$theme}]. Valid themes: " . implode(', ', array_keys(self::STYLES)) . '.');

                return self::FAILURE;
            }
        }

        $coords = $this->coordinates($minZ, $maxZ);
        $total  = count($coords) * count($themes);

        if ($this->option('count')) {
            $this->summarise($minZ, $maxZ, $themes, $total);

            return self::SUCCESS;
        }

        if ($total > self::MAX_TILES) {
            $this->error(sprintf(
                'Refusing to warm %s tiles (limit %s). Narrow the zoom range, or raise MAX_TILES if you mean it.',
                number_format($total),
                number_format(self::MAX_TILES)
            ));

            return self::FAILURE;
        }

        $this->summarise($minZ, $maxZ, $themes, $total);
        $this->newLine();

        $started = microtime(true);
        $bar     = $this->output->createProgressBar($total);
        $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%  %message%  eta %estimated:-6s%');
        $bar->setMessage('starting');
        $bar->start();

        foreach ($themes as $theme) {
            $bar->setMessage($theme);

            foreach ($coords as [$z, $x, $y]) {
                $this->warmTile($theme, $z, $x, $y);
                $bar->advance();
            }
        }

        $bar->setMessage('done');
        $bar->finish();
        $this->newLine(2);

        $this->retryFailures();
        $this->report(microtime(true) - $started);

        return empty($this->failed) ? self::SUCCESS : self::FAILURE;
    }

    /**
     * Every tile coordinate in the global grid across the given zoom range.
     *
     * @return array<int, array{0: int, 1: int, 2: int}>  [z, x, y] triples
     */
    private function coordinates(int $minZ, int $maxZ): array
    {
        $out = [];

        for ($z = $minZ; $z <= $maxZ; $z++) {
            $n = 1 << $z;

            for ($x = 0; $x < $n; $x++) {
                for ($y = 0; $y < $n; $y++) {
                    $out[] = [$z, $x, $y];
                }
            }
        }

        return $out;
    }

    /**
     * Fetch a single tile and write it to Redis.
     *
     * Skipped tiles cost nothing and are not delayed, so re-runs over a
     * mostly-warm cache finish in seconds rather than hours.
     */
    private function warmTile(string $theme, int $z, int $x, int $y): void
    {
        $key = $this->key($theme, $z, $x, $y);

        if (! $this->option('force') && Redis::exists($key)) {
            $this->skipped++;

            return;
        }

        try {
            $response = Http::timeout(15)->connectTimeout(5)->get($this->url($theme, $z, $x, $y), [
                'key' => env('MAP_KEY')
            ]);
        } catch (\Throwable) {
            $this->failed[] = [$theme, $z, $x, $y];
            $this->pause();

            return;
        }

        $body = $response->successful() ? $response->body() : '';

        if ($body === '') {
            $this->failed[] = [$theme, $z, $x, $y];
            $this->pause();

            return;
        }

        // No TTL: volatile-lru cannot evict a key that has no expiry set.
        Redis::set($key, base64_encode($body));

        $this->stored++;
        $this->bytes += strlen($body);

        $this->pause();
    }

    /**
     * One more sequential pass over anything that failed, with backoff.
     */
    private function retryFailures(): void
    {
        if (empty($this->failed)) {
            return;
        }

        $retrying     = $this->failed;
        $this->failed = [];

        $this->line(sprintf('Retrying %s failed tile(s)...', number_format(count($retrying))));

        foreach ($retrying as [$theme, $z, $x, $y]) {
            try {
                $response = Http::retry(2, 1000)
                    ->timeout(15)
                    ->connectTimeout(5)
                    ->get($this->url($theme, $z, $x, $y), [
                        'key' => env('MAP_KEY')
                    ]);
            } catch (\Throwable) {
                $this->failed[] = [$theme, $z, $x, $y];
                $this->pause();

                continue;
            }

            $body = $response->successful() ? $response->body() : '';

            if ($body === '') {
                $this->failed[] = [$theme, $z, $x, $y];
                $this->pause();

                continue;
            }

            Redis::set($this->key($theme, $z, $x, $y), base64_encode($body));

            $this->stored++;
            $this->bytes += strlen($body);

            $this->pause();
        }

        $this->newLine();
    }

    /**
     * Sleep for the configured delay, varied by +/- the jitter, so the
     * request cadence is not a machine-perfect metronome.
     */
    private function pause(): void
    {
        $delay  = max(0, (int) $this->option('delay'));
        $jitter = max(0, (int) $this->option('jitter'));

        if ($delay === 0 && $jitter === 0) {
            return;
        }

        $ms = $jitter > 0
            ? random_int(max(0, $delay - $jitter), $delay + $jitter)
            : $delay;

        if ($ms > 0) {
            usleep($ms * 1000);
        }
    }

    private function summarise(int $minZ, int $maxZ, array $themes, int $total): void
    {
        $rows = [];

        for ($z = $minZ; $z <= $maxZ; $z++) {
            $perTheme = 4 ** $z;
            $count    = $perTheme * count($themes);

            $rows[] = [
                $z,
                number_format($perTheme),
                number_format($count),
                $this->humanBytes((int) ($count * self::ESTIMATED_BYTES)),
            ];
        }

        $rows[] = new TableSeparator();
        $rows[] = [
            'total',
            '',
            number_format($total),
            $this->humanBytes((int) ($total * self::ESTIMATED_BYTES)),
        ];

        $this->table(['zoom', 'tiles/theme', 'tiles', 'est. raw size'], $rows);

        $this->line('Themes: ' . implode(', ', $themes) . '  (size estimate excludes base64 and key overhead)');
        $this->line('Est. runtime for a cold cache: ' . $this->humanDuration($this->estimateRuntime($total)));
    }

    /** Sequential fetch time plus the average delay, for a fully cold run. */
    private function estimateRuntime(int $total): float
    {
        $perTile = ((int) $this->option('delay') / 1000) + 0.1;

        return $total * $perTile;
    }

    private function report(float $elapsed): void
    {
        $this->info(sprintf(
            'Stored %s tile(s), skipped %s already cached, %s fetched in %s.',
            number_format($this->stored),
            number_format($this->skipped),
            $this->humanBytes($this->bytes),
            $this->humanDuration($elapsed)
        ));

        if (empty($this->failed)) {
            return;
        }

        $this->newLine();
        $this->warn(sprintf('%s tile(s) could not be fetched:', number_format(count($this->failed))));

        foreach (array_slice($this->failed, 0, 20) as [$theme, $z, $x, $y]) {
            $this->line("  {$theme} {$z}/{$x}/{$y}");
        }

        if (count($this->failed) > 20) {
            $this->line('  ... and ' . number_format(count($this->failed) - 20) . ' more');
        }

        $this->newLine();
        $this->line('Re-run the command to pick these up; cached tiles are skipped unless --force is passed.');
    }

    private function key(string $theme, int $z, int $x, int $y): string
    {
        return "tile:{$theme}:{$z}:{$x}:{$y}";
    }

    private function url(string $theme, int $z, int $x, int $y): string
    {
        $style = self::STYLES[$theme];

        return "https://basemaps.cartocdn.com/rastertiles/{$style}/{$z}/{$x}/{$y}.png";
    }

    private function humanBytes(int $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i     = $bytes > 0 ? (int) floor(log($bytes, 1024)) : 0;
        $i     = min($i, count($units) - 1);

        return round($bytes / (1024 ** $i), $i > 1 ? 1 : 0) . ' ' . $units[$i];
    }

    private function humanDuration(float $seconds): string
    {
        if ($seconds < 60) {
            return round($seconds, 1) . 's';
        }

        if ($seconds < 3600) {
            return sprintf('%dm %ds', intdiv((int) $seconds, 60), (int) $seconds % 60);
        }

        return sprintf('%dh %dm', intdiv((int) $seconds, 3600), intdiv((int) $seconds % 3600, 60));
    }
}