<?php

declare(strict_types=1);

use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use Jcupitt\Vips\Config;
use Jcupitt\Vips\Image;

require dirname(__DIR__) . '/vendor/autoload.php';

const MAX_SOURCE_BYTES = 20 * 1024 * 1024;    // Original image: 20 MiB
const MAX_SOURCE_PIXELS = 80_000_000;       // 80 megapixels
const MAX_DIMENSION = 4096;               // Requested width / height
const IMAGE_QUALITY = 85;

final class HttpProblem extends RuntimeException
{
    public function __construct(public readonly int $status, string $message)
    {
        parent::__construct($message);
    }
}

function errorResponse(int $status, string $message): never
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    header('Cache-Control: no-store');

    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'HEAD') {
        echo $message . "\n";
    }
    exit;
}

function dimension(mixed $value): int
{
    if (!is_string($value) || !preg_match('/\A[1-9][0-9]*\z/D', $value)) {
        errorResponse(400, 'Invalid dimension');
    }

    $number = filter_var($value, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1, 'max_range' => MAX_DIMENSION],
    ]);
    if ($number === false) {
        errorResponse(400, 'Dimension out of range');
    }
    return $number;
}

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'GET' && $method !== 'HEAD') {
    header('Allow: GET, HEAD');
    errorResponse(405, 'Method not allowed');
}

// /get/2026/10/31/photo.jpg -> S3 key: 2026/10/31/photo.jpg
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (!is_string($requestPath)
    || preg_match('/%(?![A-Fa-f0-9]{2})/', $requestPath)
    || !preg_match('~\A/get/(.+)\z~D', $requestPath, $matches)
) {
    errorResponse(404, 'Not found');
}

$key = rawurldecode($matches[1]);
if ($key === '' || strlen($key) > 1024
    || str_starts_with($key, '/')
    || str_contains($key, '//')
    || str_contains($key, '\\')
    || str_contains($key, '?')
    || str_contains($key, '#')
    || preg_match('/[\x00-\x1F\x7F]/', $key)
    || preg_match('~(?:\A|/)\.{1,2}(?:/|\z)~', $key)
    || preg_match('/%[A-Fa-f0-9]{2}/', $key) // Disallow double-encoded paths
) {
    errorResponse(400, 'Invalid image path');
}

// Private images must have a separate verified authentication design.
if ($key === 'private' || str_starts_with($key, 'private/')) {
    errorResponse(403, 'Forbidden');
}

$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
$mimeByExt = [
    'jpg' => 'image/jpeg',
    'jpeg' => 'image/jpeg',
    'png' => 'image/png',
    'gif' => 'image/gif',
    'webp' => 'image/webp',
];
if (!isset($mimeByExt[$ext])) {
    errorResponse(415, 'Unsupported image type');
}

// Small Light precedence: csize before size.
$width = null;
$height = null;
$aspect = 's';

if (array_key_exists('csize', $_GET)) {
    $csize = $_GET['csize'];

    if (!is_string($csize)) {
        errorResponse(400, 'Invalid csize');
    }

    // 旧URL形式に対応
    // csize=320x240?v=1
    // csize=800x600?csize=340x255
    // csize=800x600?csize=340x255?v=1
    // csize=400
    if (!preg_match(
        '/\A([1-9][0-9]*)(?:x([1-9][0-9]*))?(?:\?(?:csize=[1-9][0-9]*x[1-9][0-9]*|v=[0-9]+))*\z/D',
        $csize,
        $m
    )) {
        errorResponse(400, 'Invalid csize');
    }

    // 最初のcsizeを優先
    $width = dimension($m[1]);

    if (isset($m[2]) && $m[2] !== '') {
        $height = dimension($m[2]);

        if (array_key_exists('aspect', $_GET)) {
            if (!is_string($_GET['aspect'])
                || !in_array($_GET['aspect'], ['s', 'l', 'n'], true)
            ) {
                errorResponse(400, 'Invalid aspect');
            }

            $aspect = $_GET['aspect'];
        }
    }
}


// csizeが指定されていなければsizeを確認
if ($width === null && array_key_exists('size', $_GET)) {
    $size = $_GET['size'];

    if (!is_string($size)) {
        errorResponse(400, 'Invalid size');
    }

    // 幅のみ・幅x高さ、および旧URL形式 ?size=720?v=1 に対応
    if (!preg_match(
        '/\A([1-9][0-9]*)(?:x([1-9][0-9]*))?(?:\?v=[0-9]+)?\z/D',
        $size,
        $m
    )) {
        errorResponse(400, 'Invalid size');
    }

    $width = dimension($m[1]);
    if (isset($m[2]) && $m[2] !== '') {
        $height = dimension($m[2]);
    }
}

$bucket = getenv('IMAGE_S3_BUCKET');
if ($bucket === false || trim($bucket) === '') {
    error_log('Image server: IMAGE_S3_BUCKET is not configured');
    errorResponse(503, 'Temporarily unavailable');
}
$region = getenv('AWS_REGION') ?: (getenv('AWS_DEFAULT_REGION') ?: 'ap-northeast-1');

// Native memory usage is not fully accounted for by PHP memory_limit.
Config::concurrencySet(1);
Config::cacheSetMax(20);
Config::cacheSetMaxMem(32 * 1024 * 1024);

$tempFile = tempnam(sys_get_temp_dir(), 'imgvips_');
if ($tempFile === false) {
    error_log('Image server: cannot create temporary file');
    errorResponse(503, 'Temporarily unavailable');
}

try {
    $s3 = new S3Client([
        'version' => 'latest',
        'region' => $region,
        'http' => ['connect_timeout' => 3, 'timeout' => 15],
        'retries' => 1,
    ]);

    // GetObject Body is a stream. Copy in chunks instead of loading into PHP memory.
    $result = $s3->getObject([
        'Bucket' => $bucket,
        'Key' => $key,
        '@http' => ['stream' => true],
    ]);
    if ((int) ($result['ContentLength'] ?? 0) > MAX_SOURCE_BYTES) {
        throw new HttpProblem(413, 'Source image too large');
    }

    $handle = fopen($tempFile, 'wb');
    if ($handle === false) {
        throw new RuntimeException('Cannot open temporary file');
    }

    try {
        $stream = $result['Body'];
        $count = 0;
        while (!$stream->eof()) {
            $chunk = $stream->read(64 * 1024);
            if ($chunk === '') {
                throw new RuntimeException('Premature S3 stream end');
            }
            $count += strlen($chunk);
            if ($count > MAX_SOURCE_BYTES) {
                throw new HttpProblem(413, 'Source image too large');
            }
            $written = 0;
            $chunkLength = strlen($chunk);
            while ($written < $chunkLength) {
                $n = fwrite($handle, substr($chunk, $written));
                if ($n === false || $n === 0) {
                    throw new RuntimeException('Cannot write temporary image');
                }
                $written += $n;
            }
        }
    } finally {
        fclose($handle);
    }

    $details = @getimagesize($tempFile);
    if (!is_array($details)
        || ($details['mime'] ?? '') !== $mimeByExt[$ext]
        || $details[0] < 1 || $details[1] < 1
    ) {
        throw new HttpProblem(415, 'Invalid image');
    }
    if ($details[0] * $details[1] > MAX_SOURCE_PIXELS) {
        throw new HttpProblem(413, 'Too many source pixels');
    }

    $mime = $mimeByExt[$ext];
    if ($width === null || $ext === 'gif') {
        // Leave originals and GIF animations untouched (even with size/csize).
        $output = null;
        $length = (int) filesize($tempFile);
    } else {
        $options = ['no_rotate' => true]; // Matches existing Small Light autoorient default.

        if ($height === null) {
            // ?size=300 : width-based, downsize only.
            $image = Image::thumbnail($tempFile, $width, $options + ['size' => 'down']);
        } elseif (!array_key_exists('csize', $_GET)) {
            // ?size=300x200 : fit within the bounds, downsize only.
            $image = Image::thumbnail($tempFile, $width, $options + [
                'height' => $height,
                'size' => 'down',
            ]);
        } elseif ($aspect === 's') {
            // ?csize=300x200 or aspect=s : cover + centre crop.
            $image = Image::thumbnail($tempFile, $width, $options + [
                'height' => $height,
                'size' => 'both',
                'crop' => 'centre',
            ]);
        } elseif ($aspect === 'n') {
            // aspect=n : stretch to exact width x height.
            $image = Image::thumbnail($tempFile, $width, $options + [
                'height' => $height,
                'size' => 'force',
            ]);
        } else {
            // aspect=l : fit within canvas, add white padding.
            $image = Image::thumbnail($tempFile, $width, $options + [
                'height' => $height,
                'size' => 'both',
            ]);
            $offsetX = intdiv(max(0, $width - $image->width), 2);
            $offsetY = intdiv(max(0, $height - $image->height), 2);
            $image = $image->embed($offsetX, $offsetY, $width, $height, [
                'extend' => 'white',
            ]);
        }

        $saveOptions = match ($ext) {
            'jpg', 'jpeg' => ['Q' => IMAGE_QUALITY, 'strip' => true],
            'webp' => ['Q' => IMAGE_QUALITY, 'strip' => true],
            'png' => ['compression' => 6, 'strip' => true],
            default => [],
        };
        $suffix = $ext === 'jpeg' ? '.jpg' : '.' . $ext;
        $output = $image->writeToBuffer($suffix, $saveOptions);
        $length = strlen($output);
    }

    header('Content-Type: ' . $mime);
    header('Content-Length: ' . $length);
    header('Cache-Control: public, max-age=86400, s-maxage=86400');
    header('X-Content-Type-Options: nosniff');
    if ($method !== 'HEAD') {
        if ($output === null) {
            readfile($tempFile);
        } else {
            echo $output;
        }
    }
} catch (HttpProblem $e) {
    $failure = [$e->status, $e->getMessage()];
} catch (S3Exception $e) {
    error_log('Image server S3: ' . $e->getAwsErrorCode() . ' HTTP ' . $e->getStatusCode());
    $failure = $e->getAwsErrorCode() === 'NoSuchKey' || $e->getStatusCode() === 404
        ? [404, 'Image not found']
        : [502, 'Image storage unavailable'];
} catch (Throwable $e) {
    error_log('Image server failed: ' . get_class($e) . ': ' . $e->getMessage());
    $failure = [500, 'Image processing failed'];
} finally {
    @unlink($tempFile);
}

if (isset($failure)) {
    errorResponse($failure[0], $failure[1]);
}
