<?php

namespace GuzzleHttp\Psr7;

use Exception;
use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;
use RuntimeException;

/**
 * PHP stream implementation.
 *
 * @var $stream
 */
class Stream implements StreamInterface {
	/**
	 * Resource modes.
	 *
	 * @var string
	 *
	 * @see http://php.net/manual/function.fopen.php
	 * @see http://php.net/manual/en/function.gzopen.php
	 */
	const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/';
	const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/';

	private $stream;
	private $size;
	private $seekable;
	private $readable;
	private $writable;
	private $uri;
	private $customMetadata;

	/**
	 * This constructor accepts an associative array of options.
	 *
	 * - size: (int) If a read stream would otherwise have an indeterminate
	 *   size, but the size is known due to foreknowledge, then you can
	 *   provide that size, in bytes.
	 * - metadata: (array) Any additional metadata to return when the metadata
	 *   of the stream is accessed.
	 *
	 * @param resource $stream Stream resource to wrap.
	 * @param array $options Associative array of options.
	 *
	 * @throws InvalidArgumentException if the stream is not a stream resource
	 */
	public function __construct( $stream, $options = [] ) {
		if ( ! is_resource( $stream ) ) {
			throw new InvalidArgumentException( 'Stream must be a resource' );
		}

		if ( isset( $options['size'] ) ) {
			$this->size = $options['size'];
		}

		$this->customMetadata = isset( $options['metadata'] )
			? $options['metadata']
			: [];

		$this->stream   = $stream;
		$meta           = stream_get_meta_data( $this->stream );
		$this->seekable = $meta['seekable'];
		$this->readable = (bool) preg_match( self::READABLE_MODES, $meta['mode'] );
		$this->writable = (bool) preg_match( self::WRITABLE_MODES, $meta['mode'] );
		$this->uri      = $this->getMetadata( 'uri' );
	}

	public function getMetadata( $key = null ) {
		if ( ! isset( $this->stream ) ) {
			return $key ? null : [];
		} elseif ( ! $key ) {
			return $this->customMetadata + stream_get_meta_data( $this->stream );
		} elseif ( isset( $this->customMetadata[ $key ] ) ) {
			return $this->customMetadata[ $key ];
		}

		$meta = stream_get_meta_data( $this->stream );

		return isset( $meta[ $key ] ) ? $meta[ $key ] : null;
	}

	/**
	 * Closes the stream when the destructed
	 */
	public function __destruct() {
		$this->close();
	}

	public function close() {
		if ( isset( $this->stream ) ) {
			if ( is_resource( $this->stream ) ) {
				fclose( $this->stream );
			}
			$this->detach();
		}
	}

	public function detach() {
		if ( ! isset( $this->stream ) ) {
			return null;
		}

		$result = $this->stream;
		unset( $this->stream );
		$this->size     = $this->uri = null;
		$this->readable = $this->writable = $this->seekable = false;

		return $result;
	}

	public function __toString() {
		try {
			$this->seek( 0 );

			return (string) stream_get_contents( $this->stream );
		} catch ( Exception $e ) {
			return '';
		}
	}

	public function seek( $offset, $whence = SEEK_SET ) {
		$whence = (int) $whence;

		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}
		if ( ! $this->seekable ) {
			throw new RuntimeException( 'Stream is not seekable' );
		}
		if ( fseek( $this->stream, $offset, $whence ) === - 1 ) {
			throw new RuntimeException( 'Unable to seek to stream position '
			                             . $offset . ' with whence ' . var_export( $whence, true ) );
		}
	}

	public function getContents() {
		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}

		$contents = stream_get_contents( $this->stream );

		if ( $contents === false ) {
			throw new RuntimeException( 'Unable to read stream contents' );
		}

		return $contents;
	}

	public function getSize() {
		if ( $this->size !== null ) {
			return $this->size;
		}

		if ( ! isset( $this->stream ) ) {
			return null;
		}

		// Clear the stat cache if the stream has a URI
		if ( $this->uri ) {
			clearstatcache( true, $this->uri );
		}

		$stats = fstat( $this->stream );
		if ( isset( $stats['size'] ) ) {
			$this->size = $stats['size'];

			return $this->size;
		}

		return null;
	}

	public function isReadable() {
		return $this->readable;
	}

	public function isWritable() {
		return $this->writable;
	}

	public function isSeekable() {
		return $this->seekable;
	}

	public function eof() {
		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}

		return feof( $this->stream );
	}

	public function tell() {
		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}

		$result = ftell( $this->stream );

		if ( $result === false ) {
			throw new RuntimeException( 'Unable to determine stream position' );
		}

		return $result;
	}

	public function rewind() {
		$this->seek( 0 );
	}

	public function read( $length ) {
		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}
		if ( ! $this->readable ) {
			throw new RuntimeException( 'Cannot read from non-readable stream' );
		}
		if ( $length < 0 ) {
			throw new RuntimeException( 'Length parameter cannot be negative' );
		}

		if ( 0 === $length ) {
			return '';
		}

		$string = fread( $this->stream, $length );
		if ( false === $string ) {
			throw new RuntimeException( 'Unable to read from stream' );
		}

		return $string;
	}

	public function write( $string ) {
		if ( ! isset( $this->stream ) ) {
			throw new RuntimeException( 'Stream is detached' );
		}
		if ( ! $this->writable ) {
			throw new RuntimeException( 'Cannot write to a non-writable stream' );
		}

		// We can't know the size after writing anything
		$this->size = null;
		$result     = fwrite( $this->stream, $string );

		if ( $result === false ) {
			throw new RuntimeException( 'Unable to write to stream' );
		}

		return $result;
	}
}
