<?php

/**
 * Bundles one value into a serialize()-able wrapper with readable metadata.
 */
final class PhpSerializedBundle
{
    private int $pos = 0;
    private int $len;

    private function __construct(
        private string $s,
        private int $indentSize = 2,
    ) {
        $this->len = strlen($s);
    }

    /**
     * Pack one variable into a serialize()-compatible readable wrapper.
     */
    public static function pack(mixed $value): string
    {
        $embedSerialized = serialize($value);
        $fields = [];

        $containsPhpIrObject = self::containsPhpIrObject($value);
        $containsValaIrObject = self::containsValaIrObject($value);

        if ($containsPhpIrObject) {
            $fields['pretty_phpir_dumper'] = self::dumpPhpIr($value, true);
        }

        if ($containsValaIrObject) {
            $fields['pretty_valair_dumper'] = self::dumpPhpIr($value, true);
        }

        if (self::containsPhpParserNode($value) && class_exists(\PhpParser\NodeDumper::class)) {
            $dumper = new \PhpParser\NodeDumper([
                'dumpComments' => true,
                'dumpPositions' => true,
            ]);
            $fields['pretty_nikic_dumper'] = $dumper->dump($value);
        }

        $fields['pretty'] = self::prettySerializedString($embedSerialized);
        $fields['embed_serialized'] = $embedSerialized;

        return serialize($fields);
    }

    private static function containsPhpParserNode(mixed $value): bool
    {
        if (is_object($value)) {
            return is_a($value, \PhpParser\Node::class);
        }

        if (!is_array($value)) {
            return false;
        }

        foreach ($value as $item) {
            if (self::containsPhpParserNode($item)) {
                return true;
            }
        }

        return false;
    }

    private static function containsPhpIrObject(mixed $value): bool
    {
        if (is_object($value)) {
            return str_starts_with($value::class, 'PhpIr') && method_exists($value, 'toArray');
        }

        if (!is_array($value)) {
            return false;
        }

        foreach ($value as $item) {
            if (self::containsPhpIrObject($item)) {
                return true;
            }
        }

        return false;
    }

    private static function containsValaIrObject(mixed $value): bool
    {
        if (is_object($value)) {
            return str_starts_with($value::class, 'ValaIr') && method_exists($value, 'toArray');
        }

        if (!is_array($value)) {
            return false;
        }

        foreach ($value as $item) {
            if (self::containsValaIrObject($item)) {
                return true;
            }
        }

        return false;
    }

    private static function dumpPhpIr(mixed $value, bool $includeEmbeddedResourceSummary = false): string
    {
        if (is_object($value) && method_exists($value, 'toArray')) {
            $value = $value->toArray();
        }

        if (!is_array($value)) {
            return get_debug_type($value);
        }

        $lines = self::dumpPhpIrArray($value, 0);
        if ($includeEmbeddedResourceSummary) {
            $summaryLines = self::dumpEmbeddedResourceSummary($value, $lines);
            if ($summaryLines !== []) {
                $lines = array_merge($summaryLines, $lines);
            }
        }
        return "\n" . implode("\n", $lines) . "\n";
    }

    private static function dumpEmbeddedResourceSummary(array $value, array $dumpLines): array
    {
        $resources = [];
        if (isset($value['format'], $value['root']) && is_array($value['root'])) {
            $value = $value['root'];
        }
        if (isset($value['embeddedResourceReferences']) && is_array($value['embeddedResourceReferences'])) {
            foreach ($value['embeddedResourceReferences'] as $resource) {
                if (!is_array($resource)) {
                    continue;
                }
                $resourcePath = isset($resource['resourcePath']) && is_string($resource['resourcePath']) ? $resource['resourcePath'] : '';
                $sectionName = isset($resource['sectionName']) && is_string($resource['sectionName']) ? $resource['sectionName'] : '';
                $size = isset($resource['size']) && is_int($resource['size']) ? $resource['size'] : null;
                if ($resourcePath !== '') {
                    $resources[$resourcePath] = [
                        'resourcePath' => $resourcePath,
                        'sectionName' => $sectionName,
                        'size' => $size,
                    ];
                }
            }
        }
        foreach ($dumpLines as $line) {
            if (preg_match('#phpef-resource://(/[^"\\s)]*)#', $line, $matches) !== 1) {
                continue;
            }
            $resourcePath = $matches[1];
            $resources[$resourcePath] ??= [
                'resourcePath' => $resourcePath,
                'sectionName' => '',
                'size' => null,
            ];
        }
        if ($resources === []) {
            return [];
        }
        ksort($resources);
        $lines = ['EmbeddedResources(' . count($resources) . ')'];
        foreach ($resources as $resource) {
            $label = ' EmbeddedResource(' . $resource['resourcePath'];
            if ($resource['sectionName'] !== '') {
                $label .= ' => ' . $resource['sectionName'];
            }
            if ($resource['size'] !== null) {
                $label .= ', ' . $resource['size'] . ' bytes';
            }
            $label .= ')';
            $lines[] = $label;
        }
        return $lines;
    }

    private static function dumpPhpIrArray(array $value, int $level): array
    {
        if (isset($value['format'], $value['root']) && is_array($value['root'])) {
            return self::dumpPhpIrArray($value['root'], $level);
        }

        $kind = isset($value['kind']) && is_string($value['kind']) ? $value['kind'] : 'array';
        $label = self::dumpPhpIrCompactLabel($kind, $value);

        $lines = [str_repeat(' ', $level) . $label];

        foreach (['var', 'expr', 'receiver', 'left', 'right', 'message', 'pathExpr', 'dim', 'defaultReturnExpr', 'returnExpr', 'catchReturnExpr'] as $childKey) {
            if (!isset($value[$childKey])) {
                continue;
            }
            $child = $value[$childKey];
            if (is_object($child) && method_exists($child, 'toArray')) {
                $child = $child->toArray();
            }
            if (is_array($child)) {
                array_push($lines, ...self::dumpPhpIrArray($child, $level + 1));
            }
        }

        foreach (['methods', 'methodSummaries', 'stmts', 'exprs', 'args', 'targets', 'entries', 'failureStmts', 'deferredHelpers'] as $childListKey) {
            if (!isset($value[$childListKey]) || !is_array($value[$childListKey])) {
                continue;
            }
            foreach ($value[$childListKey] as $child) {
                if (is_object($child) && method_exists($child, 'toArray')) {
                    $child = $child->toArray();
                }
                if (is_array($child)) {
                    array_push($lines, ...self::dumpPhpIrArray($child, $level + 1));
                }
            }
        }

        return $lines;
    }

    private static function dumpPhpIrCompactLabel(string $kind, array $value): string
    {
        $label = $kind;
        if ($kind === 'ClassDeclaration' && isset($value['phpName'], $value['name']) && is_string($value['phpName']) && $value['phpName'] !== '' && is_string($value['name'])) {
            return $label . '(' . $value['phpName'] . ' => ' . $value['name'] . ')';
        }
        if ($kind === 'FunctionDeclaration' && isset($value['phpName'], $value['name']) && is_string($value['phpName']) && $value['phpName'] !== '' && is_string($value['name'])) {
            return $label . '(' . $value['phpName'] . ' => ' . $value['name'] . ')';
        }
        if (array_key_exists('value', $value)) {
            $label .= '(' . self::formatPhpIrScalar($value['value']) . ')';
        } elseif (isset($value['functionName']) && is_string($value['functionName'])) {
            $label .= '(' . $value['functionName'] . ')';
        } elseif (isset($value['className']) && is_string($value['className'])) {
            $label .= '(' . $value['className'] . ')';
        } elseif (isset($value['phpMethodName']) && is_string($value['phpMethodName']) && $value['phpMethodName'] !== '') {
            $label .= '(' . $value['phpMethodName'] . ')';
        } elseif (isset($value['callee']) && is_string($value['callee'])) {
            $label .= '(' . $value['callee'] . ')';
        } elseif (isset($value['name']) && is_string($value['name'])) {
            $label .= '(' . $value['name'] . ')';
        } elseif (isset($value['operator']) && is_string($value['operator'])) {
            $label .= '(' . $value['operator'] . ')';
        }

        return $label;
    }

    private static function formatPhpIrScalar(mixed $value): string
    {
        if (is_string($value)) {
            return '"' . addcslashes($value, "\\\"\n\r\t") . '"';
        }
        if (is_int($value) || is_float($value)) {
            return (string) $value;
        }
        if (is_bool($value)) {
            return $value ? 'true' : 'false';
        }
        if ($value === null) {
            return 'null';
        }
        return get_debug_type($value);
    }

    /**
     * Unpack one readable wrapper and return the embedded original value.
     */
    public static function unpack(string $bundle): mixed
    {
        $wrapper = unserialize($bundle, ['allowed_classes' => false]);
        if (!is_array($wrapper)) {
            throw new RuntimeException('Serialized bundle is not an array wrapper');
        }
        if (!array_key_exists('embed_serialized', $wrapper)) {
            throw new RuntimeException('Serialized bundle misses required key embed_serialized');
        }
        if (!is_string($wrapper['embed_serialized'])) {
            throw new RuntimeException('Serialized bundle key embed_serialized is not a string');
        }

        $embeddedSerialized = $wrapper['embed_serialized'];
        if (($wrapper['embed_serialized_encoding'] ?? null) === 'base64') {
            $decoded = base64_decode($embeddedSerialized, true);
            if ($decoded === false) {
                throw new RuntimeException('Serialized bundle embed_serialized is not valid base64');
            }
            $embeddedSerialized = $decoded;
        }

        return unserialize($embeddedSerialized);
    }

    /**
     * Build a human-readable inspection string for one raw serialize() payload.
     */
    public static function prettySerializedString(string $serialized, int $indentSize = 2): string
    {
        $parser = new self($serialized, $indentSize);
        return $parser->parse();
    }

    private function parse(): string
    {
        $out = $this->parseValue(0);
        $this->skipWhitespace();
        if ($this->pos !== $this->len) {
            throw new RuntimeException("Trailing data at offset {$this->pos}");
        }
        return $out;
    }

    private function parseValue(int $level): string
    {
        $type = $this->readChar();
        return match ($type) {
            'N' => $this->parseNull(),
            'b' => $this->parseBool(),
            'i' => $this->parseInt(),
            'd' => $this->parseFloat(),
            's' => $this->parseString(),
            'a' => $this->parseArray($level),
            'O' => $this->parseObject($level),
            'C' => $this->parseCustomSerializedObject($level),
            'R', 'r' => $this->parseReference($type),
            default => throw new RuntimeException("Unsupported serialized type '$type' at offset " . ($this->pos - 1)),
        };
    }

    private function parseNull(): string
    {
        $this->expect(';');
        return 'null';
    }

    private function parseBool(): string
    {
        $this->expect(':');
        $v = $this->readUntil(';');
        return $v === '1' ? 'true' : 'false';
    }

    private function parseInt(): string
    {
        $this->expect(':');
        return $this->readUntil(';');
    }

    private function parseFloat(): string
    {
        $this->expect(':');
        return $this->readUntil(';');
    }

    private function parseString(): string
    {
        $this->expect(':');
        $n = $this->readLength();
        $this->expect(':');
        $this->expect('"');
        $raw = $this->readBytes($n);
        $this->expect('"');
        $this->expect(';');
        return '"' . addcslashes($raw, "\\\"\n\r\t\0") . '"';
    }

    private function parseArray(int $level): string
    {
        $this->expect(':');
        $count = $this->readLength();
        $this->expect(':');
        $this->expect('{');

        if ($count === 0) {
            $this->expect('}');
            return 'array(0) []';
        }

        $lines = ["array($count) ["];
        for ($i = 0; $i < $count; $i++) {
            $key = $this->parseValue($level + 1);
            $value = $this->parseValue($level + 1);
            $lines[] = $this->indent($level + 1) . $key . ' => ' . $this->indentMultiline($value, $level + 1);
        }
        $this->expect('}');
        $lines[] = $this->indent($level) . ']';
        return implode("\n", $lines);
    }

    private function parseObject(int $level): string
    {
        $this->expect(':');
        $classLen = $this->readLength();
        $this->expect(':');
        $this->expect('"');
        $class = $this->readBytes($classLen);
        $this->expect('"');
        $this->expect(':');
        $propCount = $this->readLength();
        $this->expect(':');
        $this->expect('{');

        if ($propCount === 0) {
            $this->expect('}');
            return "object($class)#serialized(0) {}";
        }

        $lines = ["object($class)#serialized($propCount) {"];
        for ($i = 0; $i < $propCount; $i++) {
            $key = $this->parseValue($level + 1);
            $value = $this->parseValue($level + 1);
            $lines[] = $this->indent($level + 1) . $this->formatPropertyName($key) . ' = ' . $this->indentMultiline($value, $level + 1);
        }
        $this->expect('}');
        $lines[] = $this->indent($level) . '}';
        return implode("\n", $lines);
    }

    private function parseCustomSerializedObject(int $level): string
    {
        $this->expect(':');
        $classLen = $this->readLength();
        $this->expect(':');
        $this->expect('"');
        $class = $this->readBytes($classLen);
        $this->expect('"');
        $this->expect(':');
        $payloadLen = $this->readLength();
        $this->expect(':');
        $this->expect('{');
        $payload = $this->readBytes($payloadLen);
        $this->expect('}');
        return "custom_object($class) {\n"
            . $this->indent($level + 1) . 'payload(' . $payloadLen . ') = "' . addcslashes($payload, "\\\"\n\r\t\0") . '"' . "\n"
            . $this->indent($level) . '}';
    }

    private function parseReference(string $type): string
    {
        $this->expect(':');
        $id = $this->readUntil(';');
        return $type === 'R' ? "ref(&$id)" : "object_ref($id)";
    }

    private function formatPropertyName(string $key): string
    {
        $protectedPrefix = '"\\000*\\000';
        if (str_starts_with($key, $protectedPrefix) && str_ends_with($key, '"')) {
            return 'protected $' . substr($key, strlen($protectedPrefix), -1);
        }

        $privatePrefix = '"\\000';
        if (str_starts_with($key, $privatePrefix) && str_ends_with($key, '"')) {
            $inner = substr($key, strlen($privatePrefix), -1);
            $parts = explode('\\000', $inner, 2);
            if (count($parts) === 2) {
                return 'private ' . $parts[0] . '::$' . $parts[1];
            }
        }

        return $key;
    }

    private function indentMultiline(string $text, int $level): string
    {
        if (!str_contains($text, "\n")) {
            return $text;
        }
        return str_replace("\n", "\n" . $this->indent($level), $text);
    }

    private function indent(int $level): string
    {
        return str_repeat(' ', $level * $this->indentSize);
    }

    private function readLength(): int
    {
        $n = $this->readUntil(':', false);
        if (!preg_match('/^\d+$/', $n)) {
            throw new RuntimeException("Invalid length '$n' at offset {$this->pos}");
        }
        return (int) $n;
    }

    private function readUntil(string $end, bool $consume = true): string
    {
        $p = strpos($this->s, $end, $this->pos);
        if ($p === false) {
            throw new RuntimeException("Expected '$end' after offset {$this->pos}");
        }
        $out = substr($this->s, $this->pos, $p - $this->pos);
        $this->pos = $consume ? $p + strlen($end) : $p;
        return $out;
    }

    private function readBytes(int $n): string
    {
        if ($this->pos + $n > $this->len) {
            throw new RuntimeException("Unexpected end while reading $n bytes at offset {$this->pos}");
        }
        $out = substr($this->s, $this->pos, $n);
        $this->pos += $n;
        return $out;
    }

    private function readChar(): string
    {
        if ($this->pos >= $this->len) {
            throw new RuntimeException('Unexpected end of serialized string');
        }
        return $this->s[$this->pos++];
    }

    private function expect(string $ch): void
    {
        $got = $this->readChar();
        if ($got !== $ch) {
            throw new RuntimeException("Expected '$ch', got '$got' at offset " . ($this->pos - 1));
        }
    }

    private function skipWhitespace(): void
    {
        while ($this->pos < $this->len && ctype_space($this->s[$this->pos])) {
            $this->pos++;
        }
    }
}
