1<?php
2
3declare(strict_types=1);
4
5namespace GuzzleHttp\Psr7;
6
7use Psr\Http\Message\StreamInterface;
8
9/**
10 * Reads from multiple streams, one after the other.
11 *
12 * This is a read-only stream decorator.
13 */
14final class AppendStream implements StreamInterface
15{
16    /** @var StreamInterface[] Streams being decorated */
17    private $streams = [];
18
19    /** @var bool */
20    private $seekable = true;
21
22    /** @var int */
23    private $current = 0;
24
25    /** @var int */
26    private $pos = 0;
27
28    /**
29     * @param StreamInterface[] $streams Streams to decorate. Each stream must
30     *                                   be readable.
31     */
32    public function __construct(array $streams = [])
33    {
34        foreach ($streams as $stream) {
35            $this->addStream($stream);
36        }
37    }
38
39    public function __toString(): string
40    {
41        try {
42            $this->rewind();
43
44            return $this->getContents();
45        } catch (\Throwable $e) {
46            if (\PHP_VERSION_ID >= 70400) {
47                throw $e;
48            }
49            trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
50
51            return '';
52        }
53    }
54
55    /**
56     * Add a stream to the AppendStream
57     *
58     * @param StreamInterface $stream Stream to append. Must be readable.
59     *
60     * @throws \InvalidArgumentException if the stream is not readable
61     */
62    public function addStream(StreamInterface $stream): void
63    {
64        if (!$stream->isReadable()) {
65            throw new \InvalidArgumentException('Each stream must be readable');
66        }
67
68        // The stream is only seekable if all streams are seekable
69        if (!$stream->isSeekable()) {
70            $this->seekable = false;
71        }
72
73        $this->streams[] = $stream;
74    }
75
76    public function getContents(): string
77    {
78        return Utils::copyToString($this);
79    }
80
81    /**
82     * Closes each attached stream.
83     */
84    public function close(): void
85    {
86        $this->pos = $this->current = 0;
87        $this->seekable = true;
88
89        foreach ($this->streams as $stream) {
90            $stream->close();
91        }
92
93        $this->streams = [];
94    }
95
96    /**
97     * Detaches each attached stream.
98     *
99     * Returns null as it's not clear which underlying stream resource to return.
100     */
101    public function detach()
102    {
103        $this->pos = $this->current = 0;
104        $this->seekable = true;
105
106        foreach ($this->streams as $stream) {
107            $stream->detach();
108        }
109
110        $this->streams = [];
111
112        return null;
113    }
114
115    public function tell(): int
116    {
117        return $this->pos;
118    }
119
120    /**
121     * Tries to calculate the size by adding the size of each stream.
122     *
123     * If any of the streams do not return a valid number, then the size of the
124     * append stream cannot be determined and null is returned.
125     */
126    public function getSize(): ?int
127    {
128        $size = 0;
129
130        foreach ($this->streams as $stream) {
131            $s = $stream->getSize();
132            if ($s === null) {
133                return null;
134            }
135            $size += $s;
136        }
137
138        return $size;
139    }
140
141    public function eof(): bool
142    {
143        return !$this->streams
144            || ($this->current >= count($this->streams) - 1
145             && $this->streams[$this->current]->eof());
146    }
147
148    public function rewind(): void
149    {
150        $this->seek(0);
151    }
152
153    /**
154     * Attempts to seek to the given position. Only supports SEEK_SET.
155     */
156    public function seek($offset, $whence = SEEK_SET): void
157    {
158        if (!$this->seekable) {
159            throw new \RuntimeException('This AppendStream is not seekable');
160        } elseif ($whence !== SEEK_SET) {
161            throw new \RuntimeException('The AppendStream can only seek with SEEK_SET');
162        }
163
164        $this->pos = $this->current = 0;
165
166        // Rewind each stream
167        foreach ($this->streams as $i => $stream) {
168            try {
169                $stream->rewind();
170            } catch (\Exception $e) {
171                throw new \RuntimeException('Unable to seek stream '
172                    .$i.' of the AppendStream', 0, $e);
173            }
174        }
175
176        // Seek to the actual position by reading from each stream
177        while ($this->pos < $offset && !$this->eof()) {
178            $result = $this->read(min(8096, $offset - $this->pos));
179            if ($result === '') {
180                break;
181            }
182        }
183    }
184
185    /**
186     * Reads from all of the appended streams until the length is met or EOF.
187     */
188    public function read($length): string
189    {
190        $buffer = '';
191        $total = count($this->streams) - 1;
192        $remaining = $length;
193        $progressToNext = false;
194
195        while ($remaining > 0) {
196            // Progress to the next stream if needed.
197            if ($progressToNext || $this->streams[$this->current]->eof()) {
198                $progressToNext = false;
199                if ($this->current === $total) {
200                    break;
201                }
202                ++$this->current;
203            }
204
205            $result = $this->streams[$this->current]->read($remaining);
206
207            if ($result === '') {
208                $progressToNext = true;
209                continue;
210            }
211
212            $buffer .= $result;
213            $remaining = $length - strlen($buffer);
214        }
215
216        $this->pos += strlen($buffer);
217
218        return $buffer;
219    }
220
221    public function isReadable(): bool
222    {
223        return true;
224    }
225
226    public function isWritable(): bool
227    {
228        return false;
229    }
230
231    public function isSeekable(): bool
232    {
233        return $this->seekable;
234    }
235
236    public function write($string): int
237    {
238        throw new \RuntimeException('Cannot write to an AppendStream');
239    }
240
241    /**
242     * @return mixed
243     */
244    public function getMetadata($key = null)
245    {
246        return $key ? null : [];
247    }
248}
249