<?php

declare(strict_types=1);

/**
 * APO-194 Card 4 — Canonical JSON-content base64 envelope decoder.
 *
 * The substrate primitive that lets MCP clients attach binary assets via a
 * single JSON-RPC tool call without negotiating multipart-form encoding.
 *
 * Locked in full-parity-mcp-2026-05-10.md §6:
 *
 *     {
 *       "filename": "<string>",
 *       "contentType": "<mime>",
 *       "contentBase64": "<base64 of bytes>",
 *       "sizeBytes": <int>,
 *       "sha256": "<hex 64 lowercase>"
 *     }
 *
 * Validation order: filename → contentType → size → base64-decode → sha256.
 * Any failure throws MyronJsonContentEnvelopeError, which callers translate
 * to `input_invalid` MyronActionResult errors with the specific field in
 * the message.
 *
 * The decoded payload writes to a temporary file under app/tmp/uploads/ with
 * a CSPRNG-prefixed name so existing FileRepository::uploadFile can ingest
 * it identically to a multipart-uploaded payload. Callers must call
 * MyronDecodedContent::cleanup() in a try/finally to remove the temp file
 * after ingestion.
 */
final class MyronJsonContentEnvelopeError extends RuntimeException
{
}

/**
 * Value object holding a decoded payload ready for FileRepository::uploadFile.
 */
final class MyronDecodedContent
{
    public function __construct(
        public readonly string $filename,
        public readonly string $contentType,
        public readonly int    $sizeBytes,
        public readonly string $sha256,
        public readonly string $tempPath,
    ) {
    }

    /**
     * Shape the decoded payload like a $_FILES array so FileRepository can
     * ingest it without modification.
     *
     * @return array<string, mixed>
     */
    public function asUploadedFileArray(): array
    {
        return [
            'name'     => $this->filename,
            'type'     => $this->contentType,
            'size'     => $this->sizeBytes,
            'tmp_name' => $this->tempPath,
            'error'    => UPLOAD_ERR_OK,
        ];
    }

    public function cleanup(): void
    {
        if (is_file($this->tempPath)) {
            @unlink($this->tempPath);
        }
    }
}

final class MyronJsonContentEnvelope
{
    private const MAX_BYTES = 104857600; // 100 MB hard ceiling; per-action limits sub-cap

    private const FILENAME_PATTERN = '/^[A-Za-z0-9._ -]+$/';

    public function __construct(
        private readonly string $tempDir,
        /** @var list<string>|null */
        private readonly ?array $contentTypeAllowlist = null,
        private readonly int    $perActionSizeLimit   = self::MAX_BYTES,
    ) {
    }

    /**
     * Decode an envelope. Returns a MyronDecodedContent on success. Throws
     * MyronJsonContentEnvelopeError on any validation failure, with a
     * message naming the failing field.
     *
     * @param array<string, mixed> $envelope
     */
    public function decode(array $envelope): MyronDecodedContent
    {
        // 1. Filename
        $filename = trim((string) ($envelope['filename'] ?? ''));
        if ($filename === '' || strlen($filename) > 255) {
            throw new MyronJsonContentEnvelopeError('filename: must be 1-255 characters.');
        }
        if (str_contains($filename, '..') || str_contains($filename, '/') || str_contains($filename, '\\')) {
            throw new MyronJsonContentEnvelopeError('filename: must not contain path separators or traversal sequences.');
        }
        if (preg_match(self::FILENAME_PATTERN, $filename) !== 1) {
            throw new MyronJsonContentEnvelopeError('filename: only alphanumerics, dot, underscore, dash, and space allowed.');
        }

        // 2. Content type
        $contentType = strtolower(trim((string) ($envelope['contentType'] ?? '')));
        // NB: delimiter `|` chosen so the literal `#` allowed inside MIME tokens
        // doesn't collide with the regex delimiter (the older `#`-delimited form
        // was rejected by PCRE because the inner `#` looked like a delimiter).
        if ($contentType === '' || preg_match('|^[a-z0-9!#$&^_+.\-]+/[a-z0-9!#$&^_+.\-]+$|', $contentType) !== 1) {
            throw new MyronJsonContentEnvelopeError('contentType: must be a valid MIME type.');
        }
        if ($this->contentTypeAllowlist !== null && !in_array($contentType, $this->contentTypeAllowlist, true)) {
            throw new MyronJsonContentEnvelopeError('contentType: ' . $contentType . ' is not permitted for this action.');
        }

        // 3. Size declaration
        $declaredSize = $envelope['sizeBytes'] ?? null;
        if (!is_int($declaredSize) || $declaredSize < 0) {
            throw new MyronJsonContentEnvelopeError('sizeBytes: must be a non-negative integer.');
        }
        if ($declaredSize > $this->perActionSizeLimit) {
            throw new MyronJsonContentEnvelopeError('sizeBytes: ' . $declaredSize . ' exceeds the per-action size limit of ' . $this->perActionSizeLimit . '.');
        }
        if ($declaredSize > self::MAX_BYTES) {
            throw new MyronJsonContentEnvelopeError('sizeBytes: exceeds the hard substrate ceiling.');
        }

        // 4. Base64 decode
        $base64 = (string) ($envelope['contentBase64'] ?? '');
        if ($base64 === '') {
            throw new MyronJsonContentEnvelopeError('contentBase64: must be present and non-empty.');
        }
        $bytes = base64_decode($base64, true);
        if ($bytes === false) {
            throw new MyronJsonContentEnvelopeError('contentBase64: payload is not valid base64.');
        }
        if (strlen($bytes) !== $declaredSize) {
            throw new MyronJsonContentEnvelopeError('contentBase64: decoded byte length (' . strlen($bytes) . ') does not match declared sizeBytes (' . $declaredSize . ').');
        }

        // 5. sha256 verification
        $declaredHash = strtolower(trim((string) ($envelope['sha256'] ?? '')));
        if (preg_match('/^[a-f0-9]{64}$/', $declaredHash) !== 1) {
            throw new MyronJsonContentEnvelopeError('sha256: must be 64 hex lowercase characters.');
        }
        $actualHash = hash('sha256', $bytes);
        if (!hash_equals($declaredHash, $actualHash)) {
            throw new MyronJsonContentEnvelopeError('sha256: declared digest does not match the decoded payload.');
        }

        // Write to temp file
        if (!is_dir($this->tempDir)) {
            @mkdir($this->tempDir, 0755, true);
        }
        $tempPath = $this->tempDir . '/jsonenv_' . bin2hex(random_bytes(12)) . '_' . $filename;
        $written  = file_put_contents($tempPath, $bytes);
        if ($written !== strlen($bytes)) {
            @unlink($tempPath);
            throw new MyronJsonContentEnvelopeError('temp-write: failed to persist decoded payload for ingestion.');
        }

        return new MyronDecodedContent(
            filename:    $filename,
            contentType: $contentType,
            sizeBytes:   $declaredSize,
            sha256:      $declaredHash,
            tempPath:    $tempPath,
        );
    }
}
